Time-aware greeting header, debounced search, category chip filters, and rich article cards with bylines and pull-to-refresh
import * as React from "react";
import { useState } from "react";
import { View, ScrollView, FlatList, Pressable, RefreshControl } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Badge, Button, Card, Chip, EmptyState, Glass, Icon, Image, SearchInput, Text } from "@lumioui/ui";
import { AnimatedPressable, useHaptic, usePressAnimation, useTheme } from "@lumioui/core";
import { Mount, ROW_STAGGER, ROW_STAGGER_CAP } from "../shared";
export interface FeedUser {
name: string;
avatarUrl?: string;
}
export interface FeedItem {
id: string;
title: string;
/** Deck / summary line under the title. */
subtitle?: string;
category: string;
imageUrl?: string;
badge?: string;
badgeVariant?:
| "default"
| "secondary"
| "destructive"
| "outline"
| "success"
| "warning"
| "info"
| "neutral";
/** Bylines — rendered as avatar + name in the card footer. */
author?: FeedUser;
/** Small trailing meta — "6 min read", "Playlist", "Field guide". */
meta?: string;
/** Footer trailing affordance — "$24", "Free". */
price?: string;
/** Initial heart/save state — mirrored locally so the toggle feels alive. */
saved?: boolean;
}
export interface HomeFeedScreenProps {
user?: FeedUser;
categories?: string[];
items?: FeedItem[];
/** Unread count driving the bell's dot. */
unreadCount?: number;
onItemPress?: (item: FeedItem) => void;
onToggleSave?: (item: FeedItem, saved: boolean) => void;
onSearch?: (query: string) => void;
onAvatarPress?: () => void;
onNotificationsPress?: () => void;
onCategoryPress?: (category: string) => void;
/** Called on pull-to-refresh; resolve by updating `items`. May be async. */
onRefresh?: () => void | Promise<void>;
}
const img = (seed: string) => `https://picsum.photos/seed/${seed}/800/500`;
const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;
const DEFAULT_CATEGORIES = ["For you", "Design", "Engineering", "Music", "Travel", "Food", "Fitness"];
const DEFAULT_ITEMS: FeedItem[] = [
{
id: "1",
category: "Design",
title: "The quiet craft of empty states",
subtitle: "Why the moments between content deserve your best design work.",
imageUrl: img("lumioui-feed-1"),
author: { name: "Maya Chen", avatarUrl: face(47) },
meta: "6 min read",
badge: "New",
badgeVariant: "default",
},
{
id: "2",
category: "Engineering",
title: "React Native's new architecture, explained",
subtitle: "What the bridgeless future means for your app's performance budget.",
imageUrl: img("lumioui-feed-2"),
author: { name: "Sam Okafor", avatarUrl: face(59) },
meta: "12 min read",
badge: "Trending",
badgeVariant: "success",
saved: true,
},
{
id: "3",
category: "Music",
title: "Deep Focus — a three-hour ambient mix",
subtitle: "Slow textures and tape hiss for heads-down work.",
imageUrl: img("lumioui-feed-3"),
// A station gets a photo mark, not a face avatar.
author: { name: "Sol Radio", avatarUrl: "https://picsum.photos/seed/lumioui-sol/150/150" },
meta: "Playlist",
price: "Free",
},
{
id: "4",
category: "Travel",
title: "Kyoto beyond the guidebooks",
subtitle: "Machiya cafés, moss temples, and the back streets of Arashiyama.",
imageUrl: img("lumioui-feed-4"),
author: { name: "Amara Chen", avatarUrl: face(9) },
meta: "Field guide",
badge: "Editor's pick",
badgeVariant: "secondary",
price: "$12",
},
{
id: "5",
category: "Food",
title: "Sunday meal prep in 30 minutes",
subtitle: "Five sheet-pan recipes that carry the whole week.",
imageUrl: img("lumioui-feed-5"),
author: { name: "Jonah Reyes", avatarUrl: face(11) },
meta: "Recipes",
},
{
id: "6",
category: "Fitness",
title: "Mobility for desk workers",
subtitle: "A 10-minute daily routine for hips, spine, and shoulders.",
imageUrl: img("lumioui-feed-6"),
author: { name: "Priya Nair", avatarUrl: face(32) },
meta: "8 min read",
},
{
id: "7",
category: "Design",
title: "Typography pairings that always work",
subtitle: "Six font duos with the ratios already figured out.",
imageUrl: img("lumioui-feed-7"),
author: { name: "Nina Petrova", avatarUrl: face(20) },
meta: "5 min read",
},
];
function greeting(): string {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 18) return "Good afternoon";
return "Good evening";
}
/** Header bell — circular surface, unread dot in the corner. */
function NotificationBell({ count, onPress }: { count: number; onPress?: () => void }) {
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.88, pressedOpacity: 0.85, fast: true });
return (
<AnimatedPressable
onPress={() => {
haptics.light();
onPress?.();
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="button"
accessibilityLabel={count > 0 ? `Notifications, ${count} unread` : "Notifications"}
hitSlop={8}
className="h-11 w-11 items-center justify-center overflow-hidden rounded-full bg-white dark:bg-[#1A1A20]"
>
<Icon name="bell" size="md" color="foreground" />
{count > 0 ? (
<View
className="absolute right-2.5 top-2.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-destructive"
accessibilityElementsHidden
importantForAccessibility="no"
/>
) : null}
</AnimatedPressable>
);
}
NotificationBell.displayName = "NotificationBell";
/** Floating save affordance overlaid on the card image. */
function SaveButton({
saved,
title,
onPress,
}: {
saved: boolean;
title: string;
onPress: () => void;
}) {
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.82, pressedOpacity: 0.85, fast: true });
return (
<Glass interactive effect="clear" className="absolute right-3 top-3 rounded-full">
<AnimatedPressable
onPress={() => {
haptics.selection();
onPress();
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="button"
accessibilityLabel={saved ? `Remove ${title} from saved` : `Save ${title}`}
accessibilityState={{ selected: saved }}
hitSlop={8}
className="h-9 w-9 items-center justify-center rounded-full"
>
<Icon name="bookmark" size="sm" color={saved ? "#BEF04C" : "foreground"} filled={saved} />
</AnimatedPressable>
</Glass>
);
}
SaveButton.displayName = "SaveButton";
/** Home feed — greeting header with avatar + notification bell, search,
* category chips, and rich staggered cards with pull-to-refresh. */
export function HomeFeedScreen({
user = { name: "Amara", avatarUrl: face(9) },
categories = DEFAULT_CATEGORIES,
items = DEFAULT_ITEMS,
unreadCount = 3,
onItemPress,
onToggleSave,
onSearch,
onAvatarPress,
onNotificationsPress,
onCategoryPress,
onRefresh,
}: HomeFeedScreenProps) {
const haptics = useHaptic();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const [query, setQuery] = useState("");
const [activeCategory, setActiveCategory] = useState(categories[0] ?? "For you");
const [refreshing, setRefreshing] = useState(false);
// Local mirror so the heart flips even when the parent doesn't pass state back.
const [savedIds, setSavedIds] = useState<Set<string>>(
() => new Set(items.filter((i) => i.saved).map((i) => i.id))
);
const pickCategory = (category: string) => {
setActiveCategory(category);
// Chip fires haptics.selection() internally — don't double it.
onCategoryPress?.(category);
};
const toggleSave = (item: FeedItem) => {
const next = !savedIds.has(item.id);
setSavedIds((prev) => {
const copy = new Set(prev);
if (next) copy.add(item.id);
else copy.delete(item.id);
return copy;
});
onToggleSave?.(item, next);
};
const handleRefresh = async () => {
if (!onRefresh) return;
setRefreshing(true);
try {
// Await the callback (sync or async) so the spinner tracks real work.
await Promise.resolve(onRefresh());
} finally {
setRefreshing(false);
}
};
const visibleItems =
activeCategory === categories[0] ? items : items.filter((i) => i.category === activeCategory);
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Greeting header — avatar left, name, bell with unread dot right. */}
<Mount>
<View className="flex-row items-center gap-3 px-6 pb-2 pt-4">
<Pressable
onPress={() => {
haptics.light();
onAvatarPress?.();
}}
accessibilityRole="button"
accessibilityLabel={`Open profile for ${user.name}`}
hitSlop={8}
className="min-h-11 min-w-11 justify-center"
>
{/* The button owns the a11y label — hide the nested image. */}
<Avatar
size="md"
source={user.avatarUrl ? { uri: user.avatarUrl } : undefined}
fallback={user.name}
accessibilityElementsHidden
importantForAccessibility="no"
/>
</Pressable>
<View className="flex-1 gap-0.5">
<Text variant="muted" className="text-xs">
{greeting()} ·{" "}
{new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })}
</Text>
<Text variant="h4" accessibilityRole="header" numberOfLines={1}>
{user.name}
</Text>
</View>
<NotificationBell count={unreadCount} onPress={onNotificationsPress} />
</View>
</Mount>
{/* Search */}
<Mount delay={40}>
<View className="px-6 py-2">
<SearchInput
glass
placeholder="Search stories, topics, people…"
value={query}
// Debounced so onSearch doesn't fire per keystroke; the input's
// own display state still updates instantly.
debounceMs={300}
onChangeText={(text) => {
setQuery(text);
onSearch?.(text);
}}
returnKeyType="search"
accessibilityLabel="Search feed"
/>
</View>
</Mount>
{/* Category chips */}
<Mount delay={80}>
<ScrollView
horizontal
className="grow-0"
showsHorizontalScrollIndicator={false}
contentContainerClassName="gap-2 px-6 py-3"
keyboardShouldPersistTaps="handled"
>
{categories.map((category) => {
const active = category === activeCategory;
return (
<Chip
key={category}
variant="filter"
selected={active}
selectedClassName="border-[#BEF04C] bg-[#BEF04C]"
selectedTextClassName="font-semibold text-[#1B1B26]"
onPress={() => pickCategory(category)}
accessibilityLabel={`Filter by ${category}`}
className="min-h-11 px-4"
>
{category}
</Chip>
);
})}
</ScrollView>
</Mount>
{/* Feed — staggered card entrances. */}
<FlatList
data={visibleItems}
keyExtractor={(item) => item.id}
contentContainerClassName="gap-4 px-6 pt-1"
// Extra bottom clearance so the last card isn't crowded by a tab bar.
contentContainerStyle={{ paddingBottom: insets.bottom + 88 }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
refreshControl={
onRefresh ? (
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.primary}
colors={[colors.primary]}
progressBackgroundColor={colors.card}
/>
) : undefined
}
ListEmptyComponent={
<EmptyState
variant="search"
title="Nothing here yet"
description={`No stories in “${activeCategory}” right now.`}
action={
activeCategory !== categories[0] ? (
<Button
size="sm"
variant="secondary"
onPress={() => pickCategory(categories[0] ?? "For you")}
>
Show everything
</Button>
) : undefined
}
/>
}
renderItem={({ item, index }) => {
const saved = savedIds.has(item.id);
const badge = item.badge ? (
<View className="absolute left-3 top-3">
<Badge
variant={item.badgeVariant ?? "default"}
className={(item.badgeVariant ?? "default") === "default" ? "bg-[#BEF04C]" : undefined}
textClassName={(item.badgeVariant ?? "default") === "default" ? "text-[#1B1B26]" : undefined}
>
{item.badge}
</Badge>
</View>
) : null;
/* Lead story gets the editorial hero — image, scrim, title on
image. The rest compress into digest rows so the feed scans. */
if (index === 0 && activeCategory === categories[0]) {
return (
<Mount>
<Card
onPress={() => onItemPress?.(item)}
accessibilityLabel={item.title}
className="overflow-hidden rounded-3xl"
>
<View>
{item.imageUrl ? (
<Image
source={{ uri: item.imageUrl }}
rounded="none"
aspectRatio={1.15}
accessibilityLabel={item.title}
/>
) : (
<View
className="aspect-[23/20] items-center justify-center bg-muted"
accessible={false}
>
<Icon name="image" size="lg" color="mutedForeground" />
</View>
)}
{/* scrim — dark band behind the overlay text */}
<View className="absolute inset-0 bg-black/20" />
{badge}
<SaveButton
saved={saved}
title={item.title}
onPress={() => toggleSave(item)}
/>
<View className="absolute inset-x-0 bottom-0 gap-1.5 bg-black/55 p-4">
<View className="flex-row items-center justify-between">
<Text className="text-[11px] font-bold uppercase tracking-widest text-[#BEF04C]">
{item.category}
</Text>
{item.meta ? (
<Text className="text-xs text-white/60">{item.meta}</Text>
) : null}
</View>
<Text className="text-xl font-bold leading-6 text-white" numberOfLines={2}>
{item.title}
</Text>
{item.author ? (
<View className="mt-0.5 flex-row items-center gap-2">
<Avatar
size="sm"
source={
item.author.avatarUrl ? { uri: item.author.avatarUrl } : undefined
}
fallback={item.author.name}
alt={item.author.name}
/>
<Text className="text-xs font-medium text-white/75" numberOfLines={1}>
{item.author.name}
</Text>
</View>
) : null}
</View>
</View>
</Card>
</Mount>
);
}
return (
<Mount delay={Math.min(index * ROW_STAGGER, ROW_STAGGER_CAP)}>
<Pressable
onPress={() => onItemPress?.(item)}
accessibilityRole="button"
accessibilityLabel={item.title}
className="rounded-2xl bg-white dark:bg-[#1A1A20]"
>
<View className="flex-row items-center gap-3 p-3">
{item.imageUrl ? (
<View className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded-xl">
<Image
source={{ uri: item.imageUrl }}
rounded="none"
aspectRatio={1}
accessibilityLabel={item.title}
/>
</View>
) : (
<View className="h-[72px] w-[72px] items-center justify-center rounded-xl bg-muted">
<Icon name="image" size="md" color="mutedForeground" />
</View>
)}
<View className="min-w-0 flex-1 gap-1">
<Text className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground" numberOfLines={1}>
{item.meta ? `${item.category} · ${item.meta}` : item.category}
</Text>
<Text className="text-[15px] font-semibold leading-5" numberOfLines={2}>
{item.title}
</Text>
{item.author ? (
<Text variant="muted" className="text-xs" numberOfLines={1}>
{item.author.name}
</Text>
) : null}
</View>
<Pressable
onPress={() => toggleSave(item)}
accessibilityRole="button"
accessibilityLabel={saved ? `Remove ${item.title} from saved` : `Save ${item.title}`}
accessibilityState={{ selected: saved }}
hitSlop={8}
className="h-9 w-9 items-center justify-center"
>
<Icon name="bookmark" size="md" color={saved ? "#BEF04C" : "mutedForeground"} filled={saved} />
</Pressable>
</View>
</Pressable>
</Mount>
);
}}
/>
</SafeAreaView>
);
}
Recreate the "Home Feed Screen" screen in React Native using LumioUI.
Goal: Time-aware greeting header, debounced search, category chip filters, and rich article cards with bylines and pull-to-refresh Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add core-home-feed
```
Or pull just the components: `npx lumioui add avatar badge button card chip empty-state glass icon image search-input text`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `avatar` — Circular user image with fallback initials and status dot.
- `<Avatar>` ×2 — The button owns the a11y label — hide the nested image.
### `badge` — Compact status pill with variants, icons and pulse option.
- `<Badge>` ×1
### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×1
### `card` — Compound surface — header, title, content and footer slots.
- `<Card>` ×1
### `chip` — Compact selectable pill with press scale and ink overlay.
- `<Chip>` ×1
### `empty-state` — Illustrated placeholder with title, copy and action slot.
- `<EmptyState>` ×1
### `glass` — iOS 26 liquid-glass surface — real glass via expo-glass-effect (Expo Go) or @callstack/liquid-glass (dev builds), SVG refraction + specular rim on web, frosted fallback elsewhere; GlassContainer melts adjacent panes into one blob
- `<Glass>` ×1
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×5
### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×2
### `search-input` — Search field with clear button, debounce, focus halo — plus a glass capsule mode that floats over content like iOS 26
- `<SearchInput>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×9
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/core/home-feed.tsx` exporting HomeFeedScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { useState } from "react";
import { View, ScrollView, FlatList, Pressable, RefreshControl } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Badge, Button, Card, Chip, EmptyState, Glass, Icon, Image, SearchInput, Text } from "@lumioui/ui";
import { AnimatedPressable, useHaptic, usePressAnimation, useTheme } from "@lumioui/core";
import { Mount, ROW_STAGGER, ROW_STAGGER_CAP } from "../shared";
export interface FeedUser {
name: string;
avatarUrl?: string;
}
export interface FeedItem {
id: string;
title: string;
/** Deck / summary line under the title. */
subtitle?: string;
category: string;
imageUrl?: string;
badge?: string;
badgeVariant?:
| "default"
| "secondary"
| "destructive"
| "outline"
| "success"
| "warning"
| "info"
| "neutral";
/** Bylines — rendered as avatar + name in the card footer. */
author?: FeedUser;
/** Small trailing meta — "6 min read", "Playlist", "Field guide". */
meta?: string;
/** Footer trailing affordance — "$24", "Free". */
price?: string;
/** Initial heart/save state — mirrored locally so the toggle feels alive. */
saved?: boolean;
}
export interface HomeFeedScreenProps {
user?: FeedUser;
categories?: string[];
items?: FeedItem[];
/** Unread count driving the bell's dot. */
unreadCount?: number;
onItemPress?: (item: FeedItem) => void;
onToggleSave?: (item: FeedItem, saved: boolean) => void;
onSearch?: (query: string) => void;
onAvatarPress?: () => void;
onNotificationsPress?: () => void;
onCategoryPress?: (category: string) => void;
/** Called on pull-to-refresh; resolve by updating `items`. May be async. */
onRefresh?: () => void | Promise<void>;
}
const img = (seed: string) => `https://picsum.photos/seed/${seed}/800/500`;
const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;
const DEFAULT_CATEGORIES = ["For you", "Design", "Engineering", "Music", "Travel", "Food", "Fitness"];
const DEFAULT_ITEMS: FeedItem[] = [
{
id: "1",
category: "Design",
title: "The quiet craft of empty states",
subtitle: "Why the moments between content deserve your best design work.",
imageUrl: img("lumioui-feed-1"),
author: { name: "Maya Chen", avatarUrl: face(47) },
meta: "6 min read",
badge: "New",
badgeVariant: "default",
},
{
id: "2",
category: "Engineering",
title: "React Native's new architecture, explained",
subtitle: "What the bridgeless future means for your app's performance budget.",
imageUrl: img("lumioui-feed-2"),
author: { name: "Sam Okafor", avatarUrl: face(59) },
meta: "12 min read",
badge: "Trending",
badgeVariant: "success",
saved: true,
},
{
id: "3",
category: "Music",
title: "Deep Focus — a three-hour ambient mix",
subtitle: "Slow textures and tape hiss for heads-down work.",
imageUrl: img("lumioui-feed-3"),
// A station gets a photo mark, not a face avatar.
author: { name: "Sol Radio", avatarUrl: "https://picsum.photos/seed/lumioui-sol/150/150" },
meta: "Playlist",
price: "Free",
},
{
id: "4",
category: "Travel",
title: "Kyoto beyond the guidebooks",
subtitle: "Machiya cafés, moss temples, and the back streets of Arashiyama.",
imageUrl: img("lumioui-feed-4"),
author: { name: "Amara Chen", avatarUrl: face(9) },
meta: "Field guide",
badge: "Editor's pick",
badgeVariant: "secondary",
price: "$12",
},
{
id: "5",
category: "Food",
title: "Sunday meal prep in 30 minutes",
subtitle: "Five sheet-pan recipes that carry the whole week.",
imageUrl: img("lumioui-feed-5"),
author: { name: "Jonah Reyes", avatarUrl: face(11) },
meta: "Recipes",
},
{
id: "6",
category: "Fitness",
title: "Mobility for desk workers",
subtitle: "A 10-minute daily routine for hips, spine, and shoulders.",
imageUrl: img("lumioui-feed-6"),
author: { name: "Priya Nair", avatarUrl: face(32) },
meta: "8 min read",
},
{
id: "7",
category: "Design",
title: "Typography pairings that always work",
subtitle: "Six font duos with the ratios already figured out.",
imageUrl: img("lumioui-feed-7"),
author: { name: "Nina Petrova", avatarUrl: face(20) },
meta: "5 min read",
},
];
function greeting(): string {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 18) return "Good afternoon";
return "Good evening";
}
/** Header bell — circular surface, unread dot in the corner. */
function NotificationBell({ count, onPress }: { count: number; onPress?: () => void }) {
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.88, pressedOpacity: 0.85, fast: true });
return (
<AnimatedPressable
onPress={() => {
haptics.light();
onPress?.();
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="button"
accessibilityLabel={count > 0 ? `Notifications, ${count} unread` : "Notifications"}
hitSlop={8}
className="h-11 w-11 items-center justify-center overflow-hidden rounded-full bg-white dark:bg-[#1A1A20]"
>
<Icon name="bell" size="md" color="foreground" />
{count > 0 ? (
<View
className="absolute right-2.5 top-2.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-destructive"
accessibilityElementsHidden
importantForAccessibility="no"
/>
) : null}
</AnimatedPressable>
);
}
NotificationBell.displayName = "NotificationBell";
/** Floating save affordance overlaid on the card image. */
function SaveButton({
saved,
title,
onPress,
}: {
saved: boolean;
title: string;
onPress: () => void;
}) {
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.82, pressedOpacity: 0.85, fast: true });
return (
<Glass interactive effect="clear" className="absolute right-3 top-3 rounded-full">
<AnimatedPressable
onPress={() => {
haptics.selection();
onPress();
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="button"
accessibilityLabel={saved ? `Remove ${title} from saved` : `Save ${title}`}
accessibilityState={{ selected: saved }}
hitSlop={8}
className="h-9 w-9 items-center justify-center rounded-full"
>
<Icon name="bookmark" size="sm" color={saved ? "#BEF04C" : "foreground"} filled={saved} />
</AnimatedPressable>
</Glass>
);
}
SaveButton.displayName = "SaveButton";
/** Home feed — greeting header with avatar + notification bell, search,
* category chips, and rich staggered cards with pull-to-refresh. */
export function HomeFeedScreen({
user = { name: "Amara", avatarUrl: face(9) },
categories = DEFAULT_CATEGORIES,
items = DEFAULT_ITEMS,
unreadCount = 3,
onItemPress,
onToggleSave,
onSearch,
onAvatarPress,
onNotificationsPress,
onCategoryPress,
onRefresh,
}: HomeFeedScreenProps) {
const haptics = useHaptic();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const [query, setQuery] = useState("");
const [activeCategory, setActiveCategory] = useState(categories[0] ?? "For you");
const [refreshing, setRefreshing] = useState(false);
// Local mirror so the heart flips even when the parent doesn't pass state back.
const [savedIds, setSavedIds] = useState<Set<string>>(
() => new Set(items.filter((i) => i.saved).map((i) => i.id))
);
const pickCategory = (category: string) => {
setActiveCategory(category);
// Chip fires haptics.selection() internally — don't double it.
onCategoryPress?.(category);
};
const toggleSave = (item: FeedItem) => {
const next = !savedIds.has(item.id);
setSavedIds((prev) => {
const copy = new Set(prev);
if (next) copy.add(item.id);
else copy.delete(item.id);
return copy;
});
onToggleSave?.(item, next);
};
const handleRefresh = async () => {
if (!onRefresh) return;
setRefreshing(true);
try {
// Await the callback (sync or async) so the spinner tracks real work.
await Promise.resolve(onRefresh());
} finally {
setRefreshing(false);
}
};
const visibleItems =
activeCategory === categories[0] ? items : items.filter((i) => i.category === activeCategory);
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Greeting header — avatar left, name, bell with unread dot right. */}
<Mount>
<View className="flex-row items-center gap-3 px-6 pb-2 pt-4">
<Pressable
onPress={() => {
haptics.light();
onAvatarPress?.();
}}
accessibilityRole="button"
accessibilityLabel={`Open profile for ${user.name}`}
hitSlop={8}
className="min-h-11 min-w-11 justify-center"
>
{/* The button owns the a11y label — hide the nested image. */}
<Avatar
size="md"
source={user.avatarUrl ? { uri: user.avatarUrl } : undefined}
fallback={user.name}
accessibilityElementsHidden
importantForAccessibility="no"
/>
</Pressable>
<View className="flex-1 gap-0.5">
<Text variant="muted" className="text-xs">
{greeting()} ·{" "}
{new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })}
</Text>
<Text variant="h4" accessibilityRole="header" numberOfLines={1}>
{user.name}
</Text>
</View>
<NotificationBell count={unreadCount} onPress={onNotificationsPress} />
</View>
</Mount>
{/* Search */}
<Mount delay={40}>
<View className="px-6 py-2">
<SearchInput
glass
placeholder="Search stories, topics, people…"
value={query}
// Debounced so onSearch doesn't fire per keystroke; the input's
// own display state still updates instantly.
debounceMs={300}
onChangeText={(text) => {
setQuery(text);
onSearch?.(text);
}}
returnKeyType="search"
accessibilityLabel="Search feed"
/>
</View>
</Mount>
{/* Category chips */}
<Mount delay={80}>
<ScrollView
horizontal
className="grow-0"
showsHorizontalScrollIndicator={false}
contentContainerClassName="gap-2 px-6 py-3"
keyboardShouldPersistTaps="handled"
>
{categories.map((category) => {
const active = category === activeCategory;
return (
<Chip
key={category}
variant="filter"
selected={active}
selectedClassName="border-[#BEF04C] bg-[#BEF04C]"
selectedTextClassName="font-semibold text-[#1B1B26]"
onPress={() => pickCategory(category)}
accessibilityLabel={`Filter by ${category}`}
className="min-h-11 px-4"
>
{category}
</Chip>
);
})}
</ScrollView>
</Mount>
{/* Feed — staggered card entrances. */}
<FlatList
data={visibleItems}
keyExtractor={(item) => item.id}
contentContainerClassName="gap-4 px-6 pt-1"
// Extra bottom clearance so the last card isn't crowded by a tab bar.
contentContainerStyle={{ paddingBottom: insets.bottom + 88 }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
refreshControl={
onRefresh ? (
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.primary}
colors={[colors.primary]}
progressBackgroundColor={colors.card}
/>
) : undefined
}
ListEmptyComponent={
<EmptyState
variant="search"
title="Nothing here yet"
description={`No stories in “${activeCategory}” right now.`}
action={
activeCategory !== categories[0] ? (
<Button
size="sm"
variant="secondary"
onPress={() => pickCategory(categories[0] ?? "For you")}
>
Show everything
</Button>
) : undefined
}
/>
}
renderItem={({ item, index }) => {
const saved = savedIds.has(item.id);
const badge = item.badge ? (
<View className="absolute left-3 top-3">
<Badge
variant={item.badgeVariant ?? "default"}
className={(item.badgeVariant ?? "default") === "default" ? "bg-[#BEF04C]" : undefined}
textClassName={(item.badgeVariant ?? "default") === "default" ? "text-[#1B1B26]" : undefined}
>
{item.badge}
</Badge>
</View>
) : null;
/* Lead story gets the editorial hero — image, scrim, title on
image. The rest compress into digest rows so the feed scans. */
if (index === 0 && activeCategory === categories[0]) {
return (
<Mount>
<Card
onPress={() => onItemPress?.(item)}
accessibilityLabel={item.title}
className="overflow-hidden rounded-3xl"
>
<View>
{item.imageUrl ? (
<Image
source={{ uri: item.imageUrl }}
rounded="none"
aspectRatio={1.15}
accessibilityLabel={item.title}
/>
) : (
<View
className="aspect-[23/20] items-center justify-center bg-muted"
accessible={false}
>
<Icon name="image" size="lg" color="mutedForeground" />
</View>
)}
{/* scrim — dark band behind the overlay text */}
<View className="absolute inset-0 bg-black/20" />
{badge}
<SaveButton
saved={saved}
title={item.title}
onPress={() => toggleSave(item)}
/>
<View className="absolute inset-x-0 bottom-0 gap-1.5 bg-black/55 p-4">
<View className="flex-row items-center justify-between">
<Text className="text-[11px] font-bold uppercase tracking-widest text-[#BEF04C]">
{item.category}
</Text>
{item.meta ? (
<Text className="text-xs text-white/60">{item.meta}</Text>
) : null}
</View>
<Text className="text-xl font-bold leading-6 text-white" numberOfLines={2}>
{item.title}
</Text>
{item.author ? (
<View className="mt-0.5 flex-row items-center gap-2">
<Avatar
size="sm"
source={
item.author.avatarUrl ? { uri: item.author.avatarUrl } : undefined
}
fallback={item.author.name}
alt={item.author.name}
/>
<Text className="text-xs font-medium text-white/75" numberOfLines={1}>
{item.author.name}
</Text>
</View>
) : null}
</View>
</View>
</Card>
</Mount>
);
}
return (
<Mount delay={Math.min(index * ROW_STAGGER, ROW_STAGGER_CAP)}>
<Pressable
onPress={() => onItemPress?.(item)}
accessibilityRole="button"
accessibilityLabel={item.title}
className="rounded-2xl bg-white dark:bg-[#1A1A20]"
>
<View className="flex-row items-center gap-3 p-3">
{item.imageUrl ? (
<View className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded-xl">
<Image
source={{ uri: item.imageUrl }}
rounded="none"
aspectRatio={1}
accessibilityLabel={item.title}
/>
</View>
) : (
<View className="h-[72px] w-[72px] items-center justify-center rounded-xl bg-muted">
<Icon name="image" size="md" color="mutedForeground" />
</View>
)}
<View className="min-w-0 flex-1 gap-1">
<Text className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground" numberOfLines={1}>
{item.meta ? `${item.category} · ${item.meta}` : item.category}
</Text>
<Text className="text-[15px] font-semibold leading-5" numberOfLines={2}>
{item.title}
</Text>
{item.author ? (
<Text variant="muted" className="text-xs" numberOfLines={1}>
{item.author.name}
</Text>
) : null}
</View>
<Pressable
onPress={() => toggleSave(item)}
accessibilityRole="button"
accessibilityLabel={saved ? `Remove ${item.title} from saved` : `Save ${item.title}`}
accessibilityState={{ selected: saved }}
hitSlop={8}
className="h-9 w-9 items-center justify-center"
>
<Icon name="bookmark" size="md" color={saved ? "#BEF04C" : "mutedForeground"} filled={saved} />
</Pressable>
</View>
</Pressable>
</Mount>
);
}}
/>
</SafeAreaView>
);
}
```
Screen: https://lumioui.dev/screens/core-home-feed