v0.1 public beta early access on GitHub
Home / Screens / core-home-feed
Screen template

Home Feed Screen

Time-aware greeting header, debounced search, category chip filters, and rich article cards with bylines and pull-to-refresh

pro Expo Go ✓ iosandroid gestures: tap · scroll · pull haptics: light · selection
$ npx lumioui add core-home-feed
import { HomeFeedScreen } from "@/screens/core/home-feed";
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*react-native-svg@*nativewind@*
core/home-feed.tsxsource · yours to edit
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>
  );
}
agent prompt — paste into Claude Code, Cursor, Devin…
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
Pixel 10 · Android
9:41
Live preview — the real screen via Expo Web · open full ↗