v0.1 public beta early access on GitHub
Home / Screens / media-stories
Screen template

Stories Screen

Segmented auto-advance stories with full-bleed media, gradient scrims, hold-to-pause tap zones, and a frosted reply composer

pro Expo Go ✓ iosandroid gestures: tap haptics: light · selection
$ npx lumioui add media-stories
import { StoriesScreen } from "@/screens/media/stories";
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*react-native-svg@*nativewind@*
media/stories.tsxsource · yours to edit
import * as React from "react";
import { useState } from "react";
import { View, Pressable, TextInput, type StyleProp, type ViewStyle } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
  Easing,
  useSharedValue,
  useAnimatedStyle,
  withDelay,
  withTiming,
  cancelAnimation,
  runOnJS,
  type SharedValue,
} from "react-native-reanimated";
import { Avatar, EmptyState, Glass, Icon, Image, KeyboardAvoiding, Text } from "@lumioui/ui";
import { AnimatedPressable, cn, ease, timing, useHaptic, usePressAnimation, useReducedMotion, } from "@lumioui/core";
import { StoryChrome, StoryScrim, World } from "../shared";

// Story dwell time is content data (like a video's length), not UI motion,
// so it intentionally bypasses the timing tokens.
const STORY_DURATION = 5000;

export interface Story {
  id: string;
  authorName: string;
  authorAvatarUrl?: string;
  /** e.g. "2h" */
  time: string;
  /** Optional caption rendered over the bottom of the media. */
  caption?: string;
  /** Remote image rendered full-bleed. Ignored when renderContent is set. */
  imageUrl?: string;
  /** Per-story dwell time in ms. Default 5000. */
  duration?: number;
  /** Render custom story content; defaults to the image or a dark backdrop. */
  renderContent?: () => React.ReactNode;
}

export interface StoriesScreenProps {
  stories?: Story[];
  /** Controlled index of the active story. Omit for uncontrolled. */
  index?: number;
  /** Uncontrolled initial story. Default 0. */
  defaultIndex?: number;
  onNext?: () => void;
  onPrev?: () => void;
  /** Also fires when the last story finishes auto-advancing. */
  onClose?: () => void;
  /** Heart button in the reply bar — quick reaction without leaving. */
  onLike?: (story: Story) => void;
  /** Reply composer submit. */
  onReply?: (story: Story, text: string) => void;
}

// Story media is pinned Unsplash — each image actually shows what the
// caption claims (AGENTS.md: arbitrary picsum subjects read as fake).
const DEFAULT_STORIES: Story[] = [
  {
    id: "s1",
    authorName: "Maya Chen",
    authorAvatarUrl: "https://i.pravatar.cc/150?img=47",
    time: "2h",
    caption: "Sunset hike — above the clouds by 6am",
    imageUrl:
      "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=900&h=1600&fit=crop&q=75&auto=format",
  },
  {
    id: "s2",
    authorName: "Tom Reed",
    authorAvatarUrl: "https://i.pravatar.cc/150?img=12",
    time: "4h",
    caption: "New setup day",
    imageUrl:
      "https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=900&h=1600&fit=crop&q=75&auto=format",
  },
  {
    id: "s3",
    authorName: "Priya Nair",
    authorAvatarUrl: "https://i.pravatar.cc/150?img=32",
    time: "6h",
    caption: "Studio sneak peek",
    imageUrl:
      "https://images.unsplash.com/photo-1561070791-2526d30994b5?w=900&h=1600&fit=crop&q=75&auto=format",
  },
  {
    id: "s4",
    authorName: "Sakura Ramen",
    authorAvatarUrl:
      "https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=150&h=150&fit=crop&q=70&auto=format",
    time: "8h",
    caption: "Tonkotsu Friday — show this for a free topping",
    imageUrl:
      "https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=900&h=1600&fit=crop&q=75&auto=format",
  },
];

/** Chrome entrance — the media is already there; the UI settles over it. */
const CHROME_TIMING = { duration: timing.normal.duration, easing: Easing.bezier(...ease.out) };

/** Fade + short drop on mount, delayed. Reduced motion: fade only. */
function FadeIn({
  delay = 0,
  className,
  style,
  children,
}: {
  delay?: number;
  className?: string;
  style?: StyleProp<ViewStyle>;
  children: React.ReactNode;
}) {
  const reduce = useReducedMotion();
  const t = useSharedValue(reduce ? 1 : 0);
  React.useEffect(() => {
    if (reduce) {
      t.value = 1;
      return;
    }
    t.value = withDelay(delay, withTiming(1, CHROME_TIMING));
  }, [delay, reduce, t]);
  const animStyle = useAnimatedStyle(() => ({
    opacity: t.value,
    transform: [{ translateY: (1 - t.value) * -8 }],
  }));
  return (
    <Animated.View style={[style, animStyle]} className={className}>
      {children}
    </Animated.View>
  );
}

function Segment({
  state,
  progress,
}: {
  state: "filled" | "active" | "empty";
  progress: SharedValue<number>;
}) {
  const fillStyle = useAnimatedStyle(() => ({
    transform: [{ scaleX: state === "active" ? progress.value : state === "filled" ? 1 : 0 }],
  }));
  return (
    <View className={cn("h-1 flex-1 overflow-hidden rounded-full", StoryChrome.track)}>
      <Animated.View
        // StoryChrome fill: white sits on full-bleed imagery.
        className={cn("absolute inset-0", StoryChrome.fill)}
        style={[{ transformOrigin: "left" }, fillStyle]}
      />
    </View>
  );
}

/** Stories — segmented auto-advance progress, full-bleed media, tap zones
 *  for prev/next, hold-to-pause, and a reply composer clearing the keyboard. */
export function StoriesScreen({
  stories = DEFAULT_STORIES,
  index: controlledIndex,
  defaultIndex = 0,
  onNext,
  onPrev,
  onClose,
  onLike,
  onReply,
}: StoriesScreenProps) {
  const haptics = useHaptic();
  const insets = useSafeAreaInsets();
  const reduceMotion = useReducedMotion();
  // Composer chrome — a tighter squish reads as a "pop" on the heart.
  const likePress = usePressAnimation({ pressedScale: 0.8, fast: true });
  const sendPress = usePressAnimation({ pressedScale: 0.88, fast: true });
  const progress = useSharedValue(0);
  const [uncontrolledIndex, setUncontrolledIndex] = useState(defaultIndex);
  const [draft, setDraft] = useState("");
  const [liked, setLiked] = useState(false);
  // Resolve controlled vs uncontrolled during render, never in an effect.
  const index = controlledIndex ?? uncontrolledIndex;
  const clamped = Math.max(0, Math.min(stories.length - 1, index));
  const story = stories[clamped];
  const duration = story?.duration ?? STORY_DURATION;

  const goTo = React.useCallback(
    (next: number) => {
      if (next >= stories.length) {
        // Ran off the end — let the host dismiss the viewer.
        onClose?.();
        return;
      }
      if (next < 0) return; // stay on the first story
      if (controlledIndex === undefined) setUncontrolledIndex(next);
      if (next > index) onNext?.();
      else if (next < index) onPrev?.();
    },
    [stories.length, controlledIndex, index, onNext, onPrev, onClose]
  );

  // Always advance through the latest goTo — the withTiming completion
  // callback fires a frame later, after props may have changed.
  const goToRef = React.useRef(goTo);
  goToRef.current = goTo;

  // Hold-to-pause: cancel mid-flight and resume the remaining duration.
  const paused = React.useRef(false);
  const pauseProgress = React.useCallback(() => {
    if (reduceMotion || paused.current) return;
    paused.current = true;
    cancelAnimation(progress);
  }, [reduceMotion, progress]);
  const resumeProgress = React.useCallback(() => {
    if (reduceMotion || !paused.current) return;
    paused.current = false;
    progress.value = withTiming(
      1,
      { duration: duration * (1 - progress.value) },
      (finished) => {
        if (finished) runOnJS(goToRef.current)(clamped + 1);
      }
    );
  }, [reduceMotion, progress, duration, clamped]);

  // Drive the active segment 0→1 on the UI thread; auto-advance on finish.
  // Resets whenever the active story changes.
  React.useEffect(() => {
    if (!story) return;
    paused.current = false;
    setLiked(false);
    if (reduceMotion) {
      // Reduced motion: no timed auto-advance. Hold the segment filled so
      // it reads as "current" instead of stalled at zero.
      progress.value = 1;
      return;
    }
    progress.value = 0;
    progress.value = withTiming(1, { duration }, (finished) => {
      if (finished) runOnJS(goToRef.current)(clamped + 1);
    });
    return () => cancelAnimation(progress);
  }, [story, clamped, duration, reduceMotion, progress]);

  const submitReply = () => {
    const text = draft.trim();
    if (!text || !story) return;
    haptics.light();
    onReply?.(story, text);
    setDraft("");
  };

  const toggleLike = () => {
    if (!story) return;
    haptics.light();
    setLiked((v) => !v);
    onLike?.(story);
  };

  if (!story)
    return (
      <SafeAreaView className="flex-1 bg-[#E5E2EC] dark:bg-[#14101E]" edges={["top", "bottom"]}>
        <EmptyState
          title="No stories"
          description="New stories from people you follow will appear here."
          className="flex-1"
        />
      </SafeAreaView>
    );

  return (
    <World primary="#65A30D">
    {/* StoryChrome.surface — media surface, not a theme surface; always dark. */}
    <View className={cn("flex-1", StoryChrome.surface)}>
      {/* Full-bleed media */}
      <View className="absolute inset-0">
        {story.renderContent ? (
          story.renderContent()
        ) : story.imageUrl ? (
          <Image
            source={{ uri: story.imageUrl }}
            rounded="none"
            resizeMode="cover"
            className="h-full w-full"
            accessibilityLabel={`${story.authorName}'s story`}
          />
        ) : (
          // StoryChrome.surfaceEmpty — dark fallback backdrop keeps the
          // white overlay chrome readable.
          <View className={cn("flex-1", StoryChrome.surfaceEmpty)} />
        )}
      </View>

      {/* Scrims keep the white chrome legible on light media — real
          gradient fades (StoryScrim), no banded strips. */}
      <View pointerEvents="none" className="absolute left-0 right-0 top-0 h-32">
        <StoryScrim edge="top" />
      </View>
      <View pointerEvents="none" className="absolute bottom-0 left-0 right-0 h-40">
        <StoryScrim edge="bottom" />
      </View>

      {/* Header + tap zones + caption + composer in one flow column inside
          KeyboardAvoiding — the composer lifts clear of the keyboard and
          home indicator, and the tap zones fill the gap between chrome. */}
      <KeyboardAvoiding className="flex-1" offset={0}>
        <FadeIn style={{ paddingTop: insets.top + 8 }}>
          <View
            className="flex-row gap-1.5 px-3"
            accessible
            accessibilityRole="progressbar"
            accessibilityLabel={`Story ${clamped + 1} of ${stories.length}`}
            accessibilityValue={{ min: 1, max: stories.length, now: clamped + 1 }}
          >
            {stories.map((s, i) => (
              <Segment
                key={s.id}
                state={i < clamped ? "filled" : i === clamped ? "active" : "empty"}
                progress={progress}
              />
            ))}
          </View>
          <View className="flex-row items-center gap-3 px-4 py-2.5">
            <Avatar
              size="sm"
              source={story.authorAvatarUrl ? { uri: story.authorAvatarUrl } : undefined}
              fallback={story.authorName}
              alt={`${story.authorName}'s avatar`}
            />
            <View className="flex-1">
              {/* StoryChrome: header floats over story imagery. */}
              <Text variant="small" className={StoryChrome.text} accessibilityRole="header">
                {story.authorName}
              </Text>
              <Text
                variant="muted"
                className={cn("text-xs tabular-nums", StoryChrome.textDim)}
              >
                {story.time} ago
              </Text>
              {/* Viewer count — story authors check who saw it. */}
              <Text
                variant="muted"
                className={cn("text-[10px]", StoryChrome.textDim)}
              >
                Seen by 24
              </Text>
            </View>
            <Pressable
              onPress={() => {
                haptics.light();
                onClose?.();
              }}
              accessibilityRole="button"
              accessibilityLabel="Close stories"
              hitSlop={12}
              className="h-11 w-11 items-center justify-center"
            >
              <Icon name="x" size="md" color={StoryChrome.icon} />
            </Pressable>
          </View>
        </FadeIn>

        {/* Tap zones — left third = prev, right two-thirds = next,
            press-and-hold pauses auto-advance. In flow, so they can never
            swallow the header or the reply composer. */}
        <View className="flex-1 flex-row">
          <Pressable
            className="flex-1"
            onPressIn={pauseProgress}
            onPressOut={resumeProgress}
            onPress={() => {
              haptics.selection();
              goTo(clamped - 1);
            }}
            accessibilityRole="button"
            accessibilityLabel="Previous story. Hold to pause."
          />
          <Pressable
            className="flex-[2]"
            onPressIn={pauseProgress}
            onPressOut={resumeProgress}
            onPress={() => {
              haptics.selection();
              goTo(clamped + 1);
            }}
            accessibilityRole="button"
            accessibilityLabel="Next story. Hold to pause."
          />
        </View>

        {/* Bottom chrome — caption + frosted composer settle in after the
            header so the eye lands on the media first. */}
        <FadeIn delay={120}>
          {story.caption ? (
            <View className="px-6 pb-3" pointerEvents="none">
              {/* StoryChrome: caption floats over story imagery. */}
              <Text variant="large" numberOfLines={2} className={StoryChrome.text}>
                {story.caption}
              </Text>
            </View>
          ) : null}

          {/* Reply composer — frosted pill, heart, and send (StoryChrome). */}
          <View
            className="flex-row items-center gap-2 px-4 pt-1"
            style={{ paddingBottom: insets.bottom + 10 }}
          >
            {/* Reply field — liquid glass over the story media on iOS 26,
                frosted pill fallback elsewhere (StoryChrome.pill parity). */}
            <Glass interactive
              effect="clear"
              className={cn(
                "h-11 flex-1 flex-row items-center rounded-full px-4",
                StoryChrome.pill
              )}
            >
              <TextInput
                value={draft}
                onChangeText={setDraft}
                onSubmitEditing={submitReply}
                onFocus={pauseProgress}
                onBlur={resumeProgress}
                placeholder={`Reply to ${story.authorName}…`}
                placeholderTextColor={StoryChrome.placeholder}
                returnKeyType="send"
                className={cn("flex-1 text-base", StoryChrome.text)}
                accessibilityLabel={`Reply to ${story.authorName}`}
              />
            </Glass>
            <AnimatedPressable
              onPress={toggleLike}
              onPressIn={likePress.onPressIn}
              onPressOut={likePress.onPressOut}
              style={likePress.animatedStyle}
              accessibilityRole="button"
              accessibilityLabel={liked ? "Unlike story" : "Like story"}
              accessibilityState={{ selected: liked }}
              hitSlop={8}
              className="h-11 w-11 items-center justify-center"
            >
              <Icon name="heart" size="md" color={StoryChrome.icon} filled={liked} />
            </AnimatedPressable>
            {/* Send arms into a filled white disc — the iMessage/Instagram
                idiom for a live composer. At rest it's a glass disc. */}
            {draft.trim().length === 0 ? (
              <Glass interactive
                effect="clear"
                className={cn(
                  "h-11 w-11 items-center justify-center rounded-full opacity-50",
                  StoryChrome.pill
                )}
              >
                <Icon name="arrow-up" size="md" color={StoryChrome.icon} />
              </Glass>
            ) : (
              <AnimatedPressable
                onPress={submitReply}
                onPressIn={sendPress.onPressIn}
                onPressOut={sendPress.onPressOut}
                style={sendPress.animatedStyle}
                accessibilityRole="button"
                accessibilityLabel="Send reply"
                className={cn(
                  "h-11 w-11 items-center justify-center rounded-full",
                  StoryChrome.sendArmed
                )}
              >
                <Icon name="arrow-up" size="md" color={StoryChrome.iconOnFill} />
              </AnimatedPressable>
            )}
          </View>
        </FadeIn>
      </KeyboardAvoiding>
    </View>
    </World>
  );
}
agent prompt — paste into Claude Code, Cursor, Devin…
Recreate the "Stories Screen" screen in React Native using LumioUI.

Goal: Segmented auto-advance stories with full-bleed media, gradient scrims, hold-to-pause tap zones, and a frosted reply composer 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 media-stories
```

Or pull just the components: `npx lumioui add avatar empty-state glass icon image keyboard-avoiding 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>` ×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>` ×2 — Reply field — liquid glass over the story media on iOS 26, frosted pill fallback elsewhere (StoryChrome.pill parity).

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

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

### `keyboard-avoiding` — View that lifts above the keyboard on both platforms.
- `<KeyboardAvoiding>` ×1 — home indicator, and the tap zones fill the gap between chrome.

### `text` — Themed type scale from caption to display.
- `<Text>` ×4 — StoryChrome: header floats over story imagery.

## 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/media/stories.tsx` exporting StoriesScreen. 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, Pressable, TextInput, type StyleProp, type ViewStyle } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
  Easing,
  useSharedValue,
  useAnimatedStyle,
  withDelay,
  withTiming,
  cancelAnimation,
  runOnJS,
  type SharedValue,
} from "react-native-reanimated";
import { Avatar, EmptyState, Glass, Icon, Image, KeyboardAvoiding, Text } from "@lumioui/ui";
import { AnimatedPressable, cn, ease, timing, useHaptic, usePressAnimation, useReducedMotion, } from "@lumioui/core";
import { StoryChrome, StoryScrim, World } from "../shared";

// Story dwell time is content data (like a video's length), not UI motion,
// so it intentionally bypasses the timing tokens.
const STORY_DURATION = 5000;

export interface Story {
  id: string;
  authorName: string;
  authorAvatarUrl?: string;
  /** e.g. "2h" */
  time: string;
  /** Optional caption rendered over the bottom of the media. */
  caption?: string;
  /** Remote image rendered full-bleed. Ignored when renderContent is set. */
  imageUrl?: string;
  /** Per-story dwell time in ms. Default 5000. */
  duration?: number;
  /** Render custom story content; defaults to the image or a dark backdrop. */
  renderContent?: () => React.ReactNode;
}

export interface StoriesScreenProps {
  stories?: Story[];
  /** Controlled index of the active story. Omit for uncontrolled. */
  index?: number;
  /** Uncontrolled initial story. Default 0. */
  defaultIndex?: number;
  onNext?: () => void;
  onPrev?: () => void;
  /** Also fires when the last story finishes auto-advancing. */
  onClose?: () => void;
  /** Heart button in the reply bar — quick reaction without leaving. */
  onLike?: (story: Story) => void;
  /** Reply composer submit. */
  onReply?: (story: Story, text: string) => void;
}

// Story media is pinned Unsplash — each image actually shows what the
// caption claims (AGENTS.md: arbitrary picsum subjects read as fake).
const DEFAULT_STORIES: Story[] = [
  {
    id: "s1",
    authorName: "Maya Chen",
    authorAvatarUrl: "https://i.pravatar.cc/150?img=47",
    time: "2h",
    caption: "Sunset hike — above the clouds by 6am",
    imageUrl:
      "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=900&h=1600&fit=crop&q=75&auto=format",
  },
  {
    id: "s2",
    authorName: "Tom Reed",
    authorAvatarUrl: "https://i.pravatar.cc/150?img=12",
    time: "4h",
    caption: "New setup day",
    imageUrl:
      "https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=900&h=1600&fit=crop&q=75&auto=format",
  },
  {
    id: "s3",
    authorName: "Priya Nair",
    authorAvatarUrl: "https://i.pravatar.cc/150?img=32",
    time: "6h",
    caption: "Studio sneak peek",
    imageUrl:
      "https://images.unsplash.com/photo-1561070791-2526d30994b5?w=900&h=1600&fit=crop&q=75&auto=format",
  },
  {
    id: "s4",
    authorName: "Sakura Ramen",
    authorAvatarUrl:
      "https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=150&h=150&fit=crop&q=70&auto=format",
    time: "8h",
    caption: "Tonkotsu Friday — show this for a free topping",
    imageUrl:
      "https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=900&h=1600&fit=crop&q=75&auto=format",
  },
];

/** Chrome entrance — the media is already there; the UI settles over it. */
const CHROME_TIMING = { duration: timing.normal.duration, easing: Easing.bezier(...ease.out) };

/** Fade + short drop on mount, delayed. Reduced motion: fade only. */
function FadeIn({
  delay = 0,
  className,
  style,
  children,
}: {
  delay?: number;
  className?: string;
  style?: StyleProp<ViewStyle>;
  children: React.ReactNode;
}) {
  const reduce = useReducedMotion();
  const t = useSharedValue(reduce ? 1 : 0);
  React.useEffect(() => {
    if (reduce) {
      t.value = 1;
      return;
    }
    t.value = withDelay(delay, withTiming(1, CHROME_TIMING));
  }, [delay, reduce, t]);
  const animStyle = useAnimatedStyle(() => ({
    opacity: t.value,
    transform: [{ translateY: (1 - t.value) * -8 }],
  }));
  return (
    <Animated.View style={[style, animStyle]} className={className}>
      {children}
    </Animated.View>
  );
}

function Segment({
  state,
  progress,
}: {
  state: "filled" | "active" | "empty";
  progress: SharedValue<number>;
}) {
  const fillStyle = useAnimatedStyle(() => ({
    transform: [{ scaleX: state === "active" ? progress.value : state === "filled" ? 1 : 0 }],
  }));
  return (
    <View className={cn("h-1 flex-1 overflow-hidden rounded-full", StoryChrome.track)}>
      <Animated.View
        // StoryChrome fill: white sits on full-bleed imagery.
        className={cn("absolute inset-0", StoryChrome.fill)}
        style={[{ transformOrigin: "left" }, fillStyle]}
      />
    </View>
  );
}

/** Stories — segmented auto-advance progress, full-bleed media, tap zones
 *  for prev/next, hold-to-pause, and a reply composer clearing the keyboard. */
export function StoriesScreen({
  stories = DEFAULT_STORIES,
  index: controlledIndex,
  defaultIndex = 0,
  onNext,
  onPrev,
  onClose,
  onLike,
  onReply,
}: StoriesScreenProps) {
  const haptics = useHaptic();
  const insets = useSafeAreaInsets();
  const reduceMotion = useReducedMotion();
  // Composer chrome — a tighter squish reads as a "pop" on the heart.
  const likePress = usePressAnimation({ pressedScale: 0.8, fast: true });
  const sendPress = usePressAnimation({ pressedScale: 0.88, fast: true });
  const progress = useSharedValue(0);
  const [uncontrolledIndex, setUncontrolledIndex] = useState(defaultIndex);
  const [draft, setDraft] = useState("");
  const [liked, setLiked] = useState(false);
  // Resolve controlled vs uncontrolled during render, never in an effect.
  const index = controlledIndex ?? uncontrolledIndex;
  const clamped = Math.max(0, Math.min(stories.length - 1, index));
  const story = stories[clamped];
  const duration = story?.duration ?? STORY_DURATION;

  const goTo = React.useCallback(
    (next: number) => {
      if (next >= stories.length) {
        // Ran off the end — let the host dismiss the viewer.
        onClose?.();
        return;
      }
      if (next < 0) return; // stay on the first story
      if (controlledIndex === undefined) setUncontrolledIndex(next);
      if (next > index) onNext?.();
      else if (next < index) onPrev?.();
    },
    [stories.length, controlledIndex, index, onNext, onPrev, onClose]
  );

  // Always advance through the latest goTo — the withTiming completion
  // callback fires a frame later, after props may have changed.
  const goToRef = React.useRef(goTo);
  goToRef.current = goTo;

  // Hold-to-pause: cancel mid-flight and resume the remaining duration.
  const paused = React.useRef(false);
  const pauseProgress = React.useCallback(() => {
    if (reduceMotion || paused.current) return;
    paused.current = true;
    cancelAnimation(progress);
  }, [reduceMotion, progress]);
  const resumeProgress = React.useCallback(() => {
    if (reduceMotion || !paused.current) return;
    paused.current = false;
    progress.value = withTiming(
      1,
      { duration: duration * (1 - progress.value) },
      (finished) => {
        if (finished) runOnJS(goToRef.current)(clamped + 1);
      }
    );
  }, [reduceMotion, progress, duration, clamped]);

  // Drive the active segment 0→1 on the UI thread; auto-advance on finish.
  // Resets whenever the active story changes.
  React.useEffect(() => {
    if (!story) return;
    paused.current = false;
    setLiked(false);
    if (reduceMotion) {
      // Reduced motion: no timed auto-advance. Hold the segment filled so
      // it reads as "current" instead of stalled at zero.
      progress.value = 1;
      return;
    }
    progress.value = 0;
    progress.value = withTiming(1, { duration }, (finished) => {
      if (finished) runOnJS(goToRef.current)(clamped + 1);
    });
    return () => cancelAnimation(progress);
  }, [story, clamped, duration, reduceMotion, progress]);

  const submitReply = () => {
    const text = draft.trim();
    if (!text || !story) return;
    haptics.light();
    onReply?.(story, text);
    setDraft("");
  };

  const toggleLike = () => {
    if (!story) return;
    haptics.light();
    setLiked((v) => !v);
    onLike?.(story);
  };

  if (!story)
    return (
      <SafeAreaView className="flex-1 bg-[#E5E2EC] dark:bg-[#14101E]" edges={["top", "bottom"]}>
        <EmptyState
          title="No stories"
          description="New stories from people you follow will appear here."
          className="flex-1"
        />
      </SafeAreaView>
    );

  return (
    <World primary="#65A30D">
    {/* StoryChrome.surface — media surface, not a theme surface; always dark. */}
    <View className={cn("flex-1", StoryChrome.surface)}>
      {/* Full-bleed media */}
      <View className="absolute inset-0">
        {story.renderContent ? (
          story.renderContent()
        ) : story.imageUrl ? (
          <Image
            source={{ uri: story.imageUrl }}
            rounded="none"
            resizeMode="cover"
            className="h-full w-full"
            accessibilityLabel={`${story.authorName}'s story`}
          />
        ) : (
          // StoryChrome.surfaceEmpty — dark fallback backdrop keeps the
          // white overlay chrome readable.
          <View className={cn("flex-1", StoryChrome.surfaceEmpty)} />
        )}
      </View>

      {/* Scrims keep the white chrome legible on light media — real
          gradient fades (StoryScrim), no banded strips. */}
      <View pointerEvents="none" className="absolute left-0 right-0 top-0 h-32">
        <StoryScrim edge="top" />
      </View>
      <View pointerEvents="none" className="absolute bottom-0 left-0 right-0 h-40">
        <StoryScrim edge="bottom" />
      </View>

      {/* Header + tap zones + caption + composer in one flow column inside
          KeyboardAvoiding — the composer lifts clear of the keyboard and
          home indicator, and the tap zones fill the gap between chrome. */}
      <KeyboardAvoiding className="flex-1" offset={0}>
        <FadeIn style={{ paddingTop: insets.top + 8 }}>
          <View
            className="flex-row gap-1.5 px-3"
            accessible
            accessibilityRole="progressbar"
            accessibilityLabel={`Story ${clamped + 1} of ${stories.length}`}
            accessibilityValue={{ min: 1, max: stories.length, now: clamped + 1 }}
          >
            {stories.map((s, i) => (
              <Segment
                key={s.id}
                state={i < clamped ? "filled" : i === clamped ? "active" : "empty"}
                progress={progress}
              />
            ))}
          </View>
          <View className="flex-row items-center gap-3 px-4 py-2.5">
            <Avatar
              size="sm"
              source={story.authorAvatarUrl ? { uri: story.authorAvatarUrl } : undefined}
              fallback={story.authorName}
              alt={`${story.authorName}'s avatar`}
            />
            <View className="flex-1">
              {/* StoryChrome: header floats over story imagery. */}
              <Text variant="small" className={StoryChrome.text} accessibilityRole="header">
                {story.authorName}
              </Text>
              <Text
                variant="muted"
                className={cn("text-xs tabular-nums", StoryChrome.textDim)}
              >
                {story.time} ago
              </Text>
              {/* Viewer count — story authors check who saw it. */}
              <Text
                variant="muted"
                className={cn("text-[10px]", StoryChrome.textDim)}
              >
                Seen by 24
              </Text>
            </View>
            <Pressable
              onPress={() => {
                haptics.light();
                onClose?.();
              }}
              accessibilityRole="button"
              accessibilityLabel="Close stories"
              hitSlop={12}
              className="h-11 w-11 items-center justify-center"
            >
              <Icon name="x" size="md" color={StoryChrome.icon} />
            </Pressable>
          </View>
        </FadeIn>

        {/* Tap zones — left third = prev, right two-thirds = next,
            press-and-hold pauses auto-advance. In flow, so they can never
            swallow the header or the reply composer. */}
        <View className="flex-1 flex-row">
          <Pressable
            className="flex-1"
            onPressIn={pauseProgress}
            onPressOut={resumeProgress}
            onPress={() => {
              haptics.selection();
              goTo(clamped - 1);
            }}
            accessibilityRole="button"
            accessibilityLabel="Previous story. Hold to pause."
          />
          <Pressable
            className="flex-[2]"
            onPressIn={pauseProgress}
            onPressOut={resumeProgress}
            onPress={() => {
              haptics.selection();
              goTo(clamped + 1);
            }}
            accessibilityRole="button"
            accessibilityLabel="Next story. Hold to pause."
          />
        </View>

        {/* Bottom chrome — caption + frosted composer settle in after the
            header so the eye lands on the media first. */}
        <FadeIn delay={120}>
          {story.caption ? (
            <View className="px-6 pb-3" pointerEvents="none">
              {/* StoryChrome: caption floats over story imagery. */}
              <Text variant="large" numberOfLines={2} className={StoryChrome.text}>
                {story.caption}
              </Text>
            </View>
          ) : null}

          {/* Reply composer — frosted pill, heart, and send (StoryChrome). */}
          <View
            className="flex-row items-center gap-2 px-4 pt-1"
            style={{ paddingBottom: insets.bottom + 10 }}
          >
            {/* Reply field — liquid glass over the story media on iOS 26,
                frosted pill fallback elsewhere (StoryChrome.pill parity). */}
            <Glass interactive
              effect="clear"
              className={cn(
                "h-11 flex-1 flex-row items-center rounded-full px-4",
                StoryChrome.pill
              )}
            >
              <TextInput
                value={draft}
                onChangeText={setDraft}
                onSubmitEditing={submitReply}
                onFocus={pauseProgress}
                onBlur={resumeProgress}
                placeholder={`Reply to ${story.authorName}…`}
                placeholderTextColor={StoryChrome.placeholder}
                returnKeyType="send"
                className={cn("flex-1 text-base", StoryChrome.text)}
                accessibilityLabel={`Reply to ${story.authorName}`}
              />
            </Glass>
            <AnimatedPressable
              onPress={toggleLike}
              onPressIn={likePress.onPressIn}
              onPressOut={likePress.onPressOut}
              style={likePress.animatedStyle}
              accessibilityRole="button"
              accessibilityLabel={liked ? "Unlike story" : "Like story"}
              accessibilityState={{ selected: liked }}
              hitSlop={8}
              className="h-11 w-11 items-center justify-center"
            >
              <Icon name="heart" size="md" color={StoryChrome.icon} filled={liked} />
            </AnimatedPressable>
            {/* Send arms into a filled white disc — the iMessage/Instagram
                idiom for a live composer. At rest it's a glass disc. */}
            {draft.trim().length === 0 ? (
              <Glass interactive
                effect="clear"
                className={cn(
                  "h-11 w-11 items-center justify-center rounded-full opacity-50",
                  StoryChrome.pill
                )}
              >
                <Icon name="arrow-up" size="md" color={StoryChrome.icon} />
              </Glass>
            ) : (
              <AnimatedPressable
                onPress={submitReply}
                onPressIn={sendPress.onPressIn}
                onPressOut={sendPress.onPressOut}
                style={sendPress.animatedStyle}
                accessibilityRole="button"
                accessibilityLabel="Send reply"
                className={cn(
                  "h-11 w-11 items-center justify-center rounded-full",
                  StoryChrome.sendArmed
                )}
              >
                <Icon name="arrow-up" size="md" color={StoryChrome.iconOnFill} />
              </AnimatedPressable>
            )}
          </View>
        </FadeIn>
      </KeyboardAvoiding>
    </View>
    </World>
  );
}
```

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