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