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

Notifications Screen

Today/Earlier grouped rows with actor-byline structure, unread dots and tint, thumbnails, and swipe-to-read/delete actions

pro Expo Go ✓ iosandroid gestures: tap · scroll · swipe · pull haptics: light · selection · success
$ npx lumioui add core-notifications
import { NotificationsScreen } from "@/screens/core/notifications";
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*react-native-svg@*nativewind@*
core/notifications.tsxsource · yours to edit
import * as React from "react";
import { useState } from "react";
import { View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, EmptyState, Glass, Icon, Image, RefreshableScrollView, SwipeableCard, Text, type IconName } from "@lumioui/ui";
import { AnimatedPressable, cn, useHaptic, usePressAnimation, useTheme } from "@lumioui/core";

import { Mount, Press, ROW_STAGGER, ROW_STAGGER_CAP } from "../shared";
export interface NotificationActor {
  name: string;
  avatarUrl?: string;
}

export interface NotificationItem {
  id: string;
  /** Social notifications: actor + verb (+ optional subject, rendered inline muted). */
  actorName?: string;
  actorAvatarUrl?: string;
  /** Extra actors for stacked-avatar rows — "Maya and 2 others liked…". */
  extraActors?: NotificationActor[];
  verb?: string;
  /** Quoted target — e.g. the post title that was liked. */
  subject?: string;
  /** System notifications: single-line title instead of actor/verb. */
  title?: string;
  body?: string;
  /** Relative timestamp — "2m", "3h", "1d". */
  time: string;
  /** Group the row under "Today", "Yesterday", or "Earlier". */
  section: "today" | "yesterday" | "earlier";
  unread?: boolean;
  /** Fallback leading icon when there's no actor avatar (e.g. "bell"). */
  icon?: IconName;
  /** Trailing photo for content notifications. */
  thumbnailUrl?: string;
}

export interface NotificationsScreenProps {
  notifications?: NotificationItem[];
  onNotificationPress?: (notification: NotificationItem) => void;
  /** Swipe action: mark one row read/unread. */
  onToggleRead?: (notification: NotificationItem) => void;
  /** Swipe action: remove one row. */
  onDismiss?: (notification: NotificationItem) => void;
  onMarkAllRead?: () => void;
  /** Called on pull-to-refresh; resolve by updating `notifications`. May be async. */
  onRefresh?: () => void | Promise<void>;
}

const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;

const DEFAULT_NOTIFICATIONS: NotificationItem[] = [
  {
    id: "1",
    actorName: "Maya Chen",
    actorAvatarUrl: face(47),
    verb: "liked your article",
    subject: "“The quiet craft of empty states”",
    time: "2m",
    section: "today",
    unread: true,
    thumbnailUrl: "https://picsum.photos/seed/lumioui-note-1/200/200",
  },
  {
    id: "2",
    actorName: "Tom Reed",
    actorAvatarUrl: face(12),
    verb: "commented",
    body: "Great write-up — the spacing section especially.",
    time: "26m",
    section: "today",
    unread: true,
  },
  {
    id: "3",
    title: "Your weekly digest is ready",
    body: "5 new stories in Design and Engineering",
    icon: "mail",
    time: "3h",
    section: "today",
    unread: true,
  },
  {
    id: "4",
    actorName: "Priya Nair",
    actorAvatarUrl: face(32),
    extraActors: [
      { name: "Diego Ruiz", avatarUrl: face(53) },
      { name: "Ava Martinez", avatarUrl: face(44) },
    ],
    verb: "liked your photo",
    time: "9h",
    section: "yesterday",
    thumbnailUrl: "https://picsum.photos/seed/lumioui-note-4/200/200",
  },
  {
    id: "5",
    actorName: "Sam Okafor",
    actorAvatarUrl: face(59),
    verb: "started following you",
    time: "1d",
    section: "yesterday",
  },
  {
    id: "6",
    actorName: "Lumio",
    verb: "published",
    subject: "“Reanimated 4 migration guide”",
    time: "2d",
    section: "earlier",
  },
  {
    id: "7",
    title: "Update available",
    body: "Version 2.4 ships dark-mode fixes",
    icon: "arrow-up",
    time: "4d",
    section: "earlier",
  },
];

interface NotificationRowProps {
  item: NotificationItem;
  onPress?: (item: NotificationItem) => void;
  onToggleRead?: (item: NotificationItem) => void;
  onDismiss?: (item: NotificationItem) => void;
}

function NotificationRow({ item, onPress, onToggleRead, onDismiss }: NotificationRowProps) {
  const haptics = useHaptic();
  const { colors } = useTheme();
  const press = usePressAnimation({ pressedScale: 0.98, fast: true });

  const actorLine = item.actorName
    ? item.extraActors?.length
      ? item.extraActors.length === 1
        ? `${item.actorName} and ${item.extraActors[0]!.name}`
        : `${item.actorName} and ${item.extraActors.length} others`
      : item.actorName
    : null;

  const headline = actorLine
    ? `${actorLine} ${item.verb ?? ""}`.trim()
    : (item.title ?? "Notification");
  const a11yLabel = [headline, item.subject, item.body, `${item.time} ago`, item.unread && "unread"]
    .filter(Boolean)
    .join(", ");

  const leading = item.extraActors?.length ? (
    // Multi-actor overlap stack — bordered so faces read as separate chips.
    <View className="flex-row items-center">
      <Avatar
        size="md"
        source={item.actorAvatarUrl ? { uri: item.actorAvatarUrl } : undefined}
        fallback={item.actorName}
        alt={item.actorName}
        className="border-2 border-background"
      />
      {item.extraActors.slice(0, 2).map((actor) => (
        <Avatar
          key={actor.name}
          size="md"
          source={actor.avatarUrl ? { uri: actor.avatarUrl } : undefined}
          fallback={actor.name}
          alt={actor.name}
          className="-ml-3 border-2 border-background"
        />
      ))}
    </View>
  ) : item.actorName ? (
    <Avatar
      size="md"
      source={item.actorAvatarUrl ? { uri: item.actorAvatarUrl } : undefined}
      fallback={item.actorName}
      alt={item.actorName}
    />
  ) : (
    <View className="h-10 w-10 items-center justify-center rounded-full bg-[#F4F5F9] dark:bg-white/10">
      <Icon name={item.icon ?? "bell"} size="sm" color="foreground" />
    </View>
  );

  return (
    <SwipeableCard
      className="rounded-none"
      rightActions={[
        {
          icon: item.unread ? "check" : "mail",
          label: item.unread ? "Read" : "Unread",
          color: colors.primary,
          onPress: () => onToggleRead?.(item),
        },
        {
          icon: "trash",
          label: "Delete",
          color: colors.destructive,
          onPress: () => onDismiss?.(item),
        },
      ]}
    >
      <AnimatedPressable
        onPress={() => {
          haptics.light();
          onPress?.(item);
        }}
        onPressIn={press.onPressIn}
        onPressOut={press.onPressOut}
        style={press.animatedStyle}
        accessibilityRole="button"
        accessibilityLabel={a11yLabel}
        className={cn(
          "min-h-11 w-full flex-row items-center gap-3 px-4 py-3",
          item.unread ? "bg-[#EDF8C4] dark:bg-[#1E2410]" : "bg-white dark:bg-[#1A1A20]"
        )}
      >
        {leading}

        <View className="flex-1 gap-0.5">
          {actorLine ? (
            <Text variant="small" numberOfLines={2}>
              <Text variant="small" className="font-semibold">
                {actorLine}
              </Text>
              {item.verb ? ` ${item.verb}` : ""}
              {item.subject ? (
                <Text variant="small" className="text-muted-foreground">
                  {` ${item.subject}`}
                </Text>
              ) : null}
            </Text>
          ) : (
            <Text variant="small" className="font-semibold" numberOfLines={2}>
              {item.title}
            </Text>
          )}
          {item.body ? (
            <Text variant="muted" numberOfLines={2}>
              {item.body}
            </Text>
          ) : null}
          <Text variant="muted" className="text-xs">
            {item.time} ago
          </Text>
        </View>

        {item.unread ? (
          <View
            className="h-2 w-2 shrink-0 rounded-full bg-[#65A30D] dark:bg-[#BEF04C]"
            accessibilityElementsHidden
            importantForAccessibility="no"
          />
        ) : null}
        {item.thumbnailUrl ? (
          <Image
            source={{ uri: item.thumbnailUrl }}
            rounded="lg"
            className="h-11 w-11"
            accessibilityLabel="Related content"
          />
        ) : null}
      </AnimatedPressable>
    </SwipeableCard>
  );
}
NotificationRow.displayName = "NotificationRow";

function SectionHeader({ title, unread }: { title: string; unread: number }) {
  return (
    <View className="flex-row items-baseline justify-between px-6 pb-1 pt-6">
      <Text
        accessibilityRole="header"
        className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
      >
        {title}
      </Text>
      {unread > 0 ? (
        <Text className="text-xs font-semibold text-[#65A30D] dark:text-[#BEF04C]">{unread} new</Text>
      ) : null}
    </View>
  );
}
SectionHeader.displayName = "SectionHeader";

const SECTIONS: { key: NotificationItem["section"]; title: string }[] = [
  { key: "today", title: "Today" },
  { key: "yesterday", title: "Yesterday" },
  { key: "earlier", title: "Earlier" },
];

/** Notifications — day-grouped rows, unread tint + dots, overlap avatars,
 *  and swipe actions. */
export function NotificationsScreen({
  notifications = DEFAULT_NOTIFICATIONS,
  onNotificationPress,
  onToggleRead,
  onDismiss,
  onMarkAllRead,
  onRefresh,
}: NotificationsScreenProps) {
  const haptics = useHaptic();
  const insets = useSafeAreaInsets();
  const [refreshing, setRefreshing] = useState(false);

  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 groups = SECTIONS.map((s) => ({
    ...s,
    items: notifications.filter((n) => n.section === s.key),
  })).filter((g) => g.items.length > 0);
  const hasUnread = notifications.some((n) => n.unread);

  return (
    <SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
      {/* Floating chrome — back disc + title pill + mark-all-read pill. */}
      <View className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }} pointerEvents="box-none">
        <View className="flex-row items-center justify-between gap-2">
          <Glass
            effect="regular"
            className="h-11 w-11 items-center justify-center rounded-full"
          >
            <Press
              onPress={() => haptics.light()}
              accessibilityLabel="Go back"
              accessibilityRole="button"
              className="h-9 w-9 items-center justify-center"
            >
              <Icon name="chevron-left" size="md" color="foreground" />
            </Press>
          </Glass>
          <Glass
            effect="regular"
            className="h-11 items-center justify-center rounded-full px-4"
          >
            <Press
              onPress={() => {
                haptics.success();
                onMarkAllRead?.();
              }}
              disabled={!hasUnread}
              accessibilityLabel="Mark all notifications as read"
              accessibilityRole="button"
              accessibilityState={{ disabled: !hasUnread }}
              className="min-h-11 justify-center"
            >
              <Text className="text-sm font-semibold" style={{ opacity: hasUnread ? 1 : 0.45 }}>
                Mark all read
              </Text>
            </Press>
          </Glass>
        </View>
        <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
          <Glass
            effect="regular"
            className="h-11 flex-row items-center justify-center rounded-full px-5"
          >
            <Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
              Notifications
            </Text>
          </Glass>
        </View>
      </View>

      <RefreshableScrollView
        refreshing={refreshing}
        onRefresh={handleRefresh}
        showsVerticalScrollIndicator={false}
        contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 32 }}
      >
        {groups.map((group, gi) => {
          const unread = group.items.filter((n) => n.unread).length;
          return (
            <View key={group.key}>
              <Mount delay={40 + gi * 80}>
                <SectionHeader title={group.title} unread={unread} />
              </Mount>
              {group.items.map((item, i) => (
                <Mount
                  key={item.id}
                  delay={Math.min(80 + gi * 80 + i * ROW_STAGGER, ROW_STAGGER_CAP + gi * 80)}
                >
                  <NotificationRow
                    item={item}
                    onPress={onNotificationPress}
                    onToggleRead={onToggleRead}
                    onDismiss={onDismiss}
                  />
                </Mount>
              ))}
            </View>
          );
        })}

        {notifications.length === 0 ? (
          <EmptyState
            title="You're all caught up"
            description="New notifications will appear here."
            icon={<Icon name="bell" size="lg" color="mutedForeground" />}
            className="py-24"
          />
        ) : null}
        {notifications.length > 0 ? (
          <Text variant="muted" className="px-6 pt-6 text-center text-xs">
            Notifications older than 30 days are cleared automatically
          </Text>
        ) : null}
      </RefreshableScrollView>
    </SafeAreaView>
  );
}
agent prompt — paste into Claude Code, Cursor, Devin…
Recreate the "Notifications Screen" screen in React Native using LumioUI.

Goal: Today/Earlier grouped rows with actor-byline structure, unread dots and tint, thumbnails, and swipe-to-read/delete actions 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-notifications
```

Or pull just the components: `npx lumioui add avatar button card empty-state icon image pull-to-refresh 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>` ×3

### `button` — Tactile pill button with press spring, ink wash and async loading.
- imported but not rendered directly — likely used through a sibling or as a utility

### `card` — Compound surface — header, title, content and footer slots.
- `<SwipeableCard>` ×1

### `empty-state` — Illustrated placeholder with title, copy and action slot.
- `<EmptyState>` ×1

### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×3

### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×1

### `pull-to-refresh` — Physics-driven pull-to-refresh ScrollView — resisted pull, threshold snap, custom ring indicator.
- `<RefreshableScrollView>` ×1

### `text` — Themed type scale from caption to display.
- `<Text>` ×11

## 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/notifications.tsx` exporting NotificationsScreen. 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 } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, EmptyState, Glass, Icon, Image, RefreshableScrollView, SwipeableCard, Text, type IconName } from "@lumioui/ui";
import { AnimatedPressable, cn, useHaptic, usePressAnimation, useTheme } from "@lumioui/core";

import { Mount, Press, ROW_STAGGER, ROW_STAGGER_CAP } from "../shared";
export interface NotificationActor {
  name: string;
  avatarUrl?: string;
}

export interface NotificationItem {
  id: string;
  /** Social notifications: actor + verb (+ optional subject, rendered inline muted). */
  actorName?: string;
  actorAvatarUrl?: string;
  /** Extra actors for stacked-avatar rows — "Maya and 2 others liked…". */
  extraActors?: NotificationActor[];
  verb?: string;
  /** Quoted target — e.g. the post title that was liked. */
  subject?: string;
  /** System notifications: single-line title instead of actor/verb. */
  title?: string;
  body?: string;
  /** Relative timestamp — "2m", "3h", "1d". */
  time: string;
  /** Group the row under "Today", "Yesterday", or "Earlier". */
  section: "today" | "yesterday" | "earlier";
  unread?: boolean;
  /** Fallback leading icon when there's no actor avatar (e.g. "bell"). */
  icon?: IconName;
  /** Trailing photo for content notifications. */
  thumbnailUrl?: string;
}

export interface NotificationsScreenProps {
  notifications?: NotificationItem[];
  onNotificationPress?: (notification: NotificationItem) => void;
  /** Swipe action: mark one row read/unread. */
  onToggleRead?: (notification: NotificationItem) => void;
  /** Swipe action: remove one row. */
  onDismiss?: (notification: NotificationItem) => void;
  onMarkAllRead?: () => void;
  /** Called on pull-to-refresh; resolve by updating `notifications`. May be async. */
  onRefresh?: () => void | Promise<void>;
}

const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;

const DEFAULT_NOTIFICATIONS: NotificationItem[] = [
  {
    id: "1",
    actorName: "Maya Chen",
    actorAvatarUrl: face(47),
    verb: "liked your article",
    subject: "“The quiet craft of empty states”",
    time: "2m",
    section: "today",
    unread: true,
    thumbnailUrl: "https://picsum.photos/seed/lumioui-note-1/200/200",
  },
  {
    id: "2",
    actorName: "Tom Reed",
    actorAvatarUrl: face(12),
    verb: "commented",
    body: "Great write-up — the spacing section especially.",
    time: "26m",
    section: "today",
    unread: true,
  },
  {
    id: "3",
    title: "Your weekly digest is ready",
    body: "5 new stories in Design and Engineering",
    icon: "mail",
    time: "3h",
    section: "today",
    unread: true,
  },
  {
    id: "4",
    actorName: "Priya Nair",
    actorAvatarUrl: face(32),
    extraActors: [
      { name: "Diego Ruiz", avatarUrl: face(53) },
      { name: "Ava Martinez", avatarUrl: face(44) },
    ],
    verb: "liked your photo",
    time: "9h",
    section: "yesterday",
    thumbnailUrl: "https://picsum.photos/seed/lumioui-note-4/200/200",
  },
  {
    id: "5",
    actorName: "Sam Okafor",
    actorAvatarUrl: face(59),
    verb: "started following you",
    time: "1d",
    section: "yesterday",
  },
  {
    id: "6",
    actorName: "Lumio",
    verb: "published",
    subject: "“Reanimated 4 migration guide”",
    time: "2d",
    section: "earlier",
  },
  {
    id: "7",
    title: "Update available",
    body: "Version 2.4 ships dark-mode fixes",
    icon: "arrow-up",
    time: "4d",
    section: "earlier",
  },
];

interface NotificationRowProps {
  item: NotificationItem;
  onPress?: (item: NotificationItem) => void;
  onToggleRead?: (item: NotificationItem) => void;
  onDismiss?: (item: NotificationItem) => void;
}

function NotificationRow({ item, onPress, onToggleRead, onDismiss }: NotificationRowProps) {
  const haptics = useHaptic();
  const { colors } = useTheme();
  const press = usePressAnimation({ pressedScale: 0.98, fast: true });

  const actorLine = item.actorName
    ? item.extraActors?.length
      ? item.extraActors.length === 1
        ? `${item.actorName} and ${item.extraActors[0]!.name}`
        : `${item.actorName} and ${item.extraActors.length} others`
      : item.actorName
    : null;

  const headline = actorLine
    ? `${actorLine} ${item.verb ?? ""}`.trim()
    : (item.title ?? "Notification");
  const a11yLabel = [headline, item.subject, item.body, `${item.time} ago`, item.unread && "unread"]
    .filter(Boolean)
    .join(", ");

  const leading = item.extraActors?.length ? (
    // Multi-actor overlap stack — bordered so faces read as separate chips.
    <View className="flex-row items-center">
      <Avatar
        size="md"
        source={item.actorAvatarUrl ? { uri: item.actorAvatarUrl } : undefined}
        fallback={item.actorName}
        alt={item.actorName}
        className="border-2 border-background"
      />
      {item.extraActors.slice(0, 2).map((actor) => (
        <Avatar
          key={actor.name}
          size="md"
          source={actor.avatarUrl ? { uri: actor.avatarUrl } : undefined}
          fallback={actor.name}
          alt={actor.name}
          className="-ml-3 border-2 border-background"
        />
      ))}
    </View>
  ) : item.actorName ? (
    <Avatar
      size="md"
      source={item.actorAvatarUrl ? { uri: item.actorAvatarUrl } : undefined}
      fallback={item.actorName}
      alt={item.actorName}
    />
  ) : (
    <View className="h-10 w-10 items-center justify-center rounded-full bg-[#F4F5F9] dark:bg-white/10">
      <Icon name={item.icon ?? "bell"} size="sm" color="foreground" />
    </View>
  );

  return (
    <SwipeableCard
      className="rounded-none"
      rightActions={[
        {
          icon: item.unread ? "check" : "mail",
          label: item.unread ? "Read" : "Unread",
          color: colors.primary,
          onPress: () => onToggleRead?.(item),
        },
        {
          icon: "trash",
          label: "Delete",
          color: colors.destructive,
          onPress: () => onDismiss?.(item),
        },
      ]}
    >
      <AnimatedPressable
        onPress={() => {
          haptics.light();
          onPress?.(item);
        }}
        onPressIn={press.onPressIn}
        onPressOut={press.onPressOut}
        style={press.animatedStyle}
        accessibilityRole="button"
        accessibilityLabel={a11yLabel}
        className={cn(
          "min-h-11 w-full flex-row items-center gap-3 px-4 py-3",
          item.unread ? "bg-[#EDF8C4] dark:bg-[#1E2410]" : "bg-white dark:bg-[#1A1A20]"
        )}
      >
        {leading}

        <View className="flex-1 gap-0.5">
          {actorLine ? (
            <Text variant="small" numberOfLines={2}>
              <Text variant="small" className="font-semibold">
                {actorLine}
              </Text>
              {item.verb ? ` ${item.verb}` : ""}
              {item.subject ? (
                <Text variant="small" className="text-muted-foreground">
                  {` ${item.subject}`}
                </Text>
              ) : null}
            </Text>
          ) : (
            <Text variant="small" className="font-semibold" numberOfLines={2}>
              {item.title}
            </Text>
          )}
          {item.body ? (
            <Text variant="muted" numberOfLines={2}>
              {item.body}
            </Text>
          ) : null}
          <Text variant="muted" className="text-xs">
            {item.time} ago
          </Text>
        </View>

        {item.unread ? (
          <View
            className="h-2 w-2 shrink-0 rounded-full bg-[#65A30D] dark:bg-[#BEF04C]"
            accessibilityElementsHidden
            importantForAccessibility="no"
          />
        ) : null}
        {item.thumbnailUrl ? (
          <Image
            source={{ uri: item.thumbnailUrl }}
            rounded="lg"
            className="h-11 w-11"
            accessibilityLabel="Related content"
          />
        ) : null}
      </AnimatedPressable>
    </SwipeableCard>
  );
}
NotificationRow.displayName = "NotificationRow";

function SectionHeader({ title, unread }: { title: string; unread: number }) {
  return (
    <View className="flex-row items-baseline justify-between px-6 pb-1 pt-6">
      <Text
        accessibilityRole="header"
        className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
      >
        {title}
      </Text>
      {unread > 0 ? (
        <Text className="text-xs font-semibold text-[#65A30D] dark:text-[#BEF04C]">{unread} new</Text>
      ) : null}
    </View>
  );
}
SectionHeader.displayName = "SectionHeader";

const SECTIONS: { key: NotificationItem["section"]; title: string }[] = [
  { key: "today", title: "Today" },
  { key: "yesterday", title: "Yesterday" },
  { key: "earlier", title: "Earlier" },
];

/** Notifications — day-grouped rows, unread tint + dots, overlap avatars,
 *  and swipe actions. */
export function NotificationsScreen({
  notifications = DEFAULT_NOTIFICATIONS,
  onNotificationPress,
  onToggleRead,
  onDismiss,
  onMarkAllRead,
  onRefresh,
}: NotificationsScreenProps) {
  const haptics = useHaptic();
  const insets = useSafeAreaInsets();
  const [refreshing, setRefreshing] = useState(false);

  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 groups = SECTIONS.map((s) => ({
    ...s,
    items: notifications.filter((n) => n.section === s.key),
  })).filter((g) => g.items.length > 0);
  const hasUnread = notifications.some((n) => n.unread);

  return (
    <SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
      {/* Floating chrome — back disc + title pill + mark-all-read pill. */}
      <View className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }} pointerEvents="box-none">
        <View className="flex-row items-center justify-between gap-2">
          <Glass
            effect="regular"
            className="h-11 w-11 items-center justify-center rounded-full"
          >
            <Press
              onPress={() => haptics.light()}
              accessibilityLabel="Go back"
              accessibilityRole="button"
              className="h-9 w-9 items-center justify-center"
            >
              <Icon name="chevron-left" size="md" color="foreground" />
            </Press>
          </Glass>
          <Glass
            effect="regular"
            className="h-11 items-center justify-center rounded-full px-4"
          >
            <Press
              onPress={() => {
                haptics.success();
                onMarkAllRead?.();
              }}
              disabled={!hasUnread}
              accessibilityLabel="Mark all notifications as read"
              accessibilityRole="button"
              accessibilityState={{ disabled: !hasUnread }}
              className="min-h-11 justify-center"
            >
              <Text className="text-sm font-semibold" style={{ opacity: hasUnread ? 1 : 0.45 }}>
                Mark all read
              </Text>
            </Press>
          </Glass>
        </View>
        <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
          <Glass
            effect="regular"
            className="h-11 flex-row items-center justify-center rounded-full px-5"
          >
            <Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
              Notifications
            </Text>
          </Glass>
        </View>
      </View>

      <RefreshableScrollView
        refreshing={refreshing}
        onRefresh={handleRefresh}
        showsVerticalScrollIndicator={false}
        contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 32 }}
      >
        {groups.map((group, gi) => {
          const unread = group.items.filter((n) => n.unread).length;
          return (
            <View key={group.key}>
              <Mount delay={40 + gi * 80}>
                <SectionHeader title={group.title} unread={unread} />
              </Mount>
              {group.items.map((item, i) => (
                <Mount
                  key={item.id}
                  delay={Math.min(80 + gi * 80 + i * ROW_STAGGER, ROW_STAGGER_CAP + gi * 80)}
                >
                  <NotificationRow
                    item={item}
                    onPress={onNotificationPress}
                    onToggleRead={onToggleRead}
                    onDismiss={onDismiss}
                  />
                </Mount>
              ))}
            </View>
          );
        })}

        {notifications.length === 0 ? (
          <EmptyState
            title="You're all caught up"
            description="New notifications will appear here."
            icon={<Icon name="bell" size="lg" color="mutedForeground" />}
            className="py-24"
          />
        ) : null}
        {notifications.length > 0 ? (
          <Text variant="muted" className="px-6 pt-6 text-center text-xs">
            Notifications older than 30 days are cleared automatically
          </Text>
        ) : null}
      </RefreshableScrollView>
    </SafeAreaView>
  );
}
```

Screen: https://lumioui.dev/screens/core-notifications
iPhone 17 · iOS
9:41
Live preview — the real screen via Expo Web · open full ↗