Full-screen story viewer with animated segmented progress bars, tap-zone navigation, hold-to-pause, and swipe-down dismiss
components/ui/stories-viewer/stories-viewer.tsx@lumioui/core@latest@lumioui/ui@latestreact-native-reanimated@^4.0.0react-native-gesture-handler@^2.20.0react-native-safe-area-context@^4.0.0expo-haptics@*| open | boolean | |
| stories | Story[] | |
| onClose | () => void | |
| index? | number | Controlled index of the visible story. Omit (with defaultIndex) for uncontrolled. |
| defaultIndex? | number | Uncontrolled initial story. Default 0. |
| onIndexChange? | (index: number) => void | |
| className? | string |
Full-screen story viewer with animated segmented progress bars, tap-zone navigation, hold-to-pause, and swipe-down dismiss
import { StoriesViewer } from "@/components/ui/stories-viewer/stories-viewer";
export function Example() {
return (
<StoriesViewer />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Image, Modal, Pressable, Text, View, type AccessibilityActionEvent, type ImageSourcePropType, type ViewProps, } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
cancelAnimation,
interpolate,
runOnJS,
Easing,
type SharedValue,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { cn, spring, timing, ease, useHaptic, useReducedMotion, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { Icon } from "@lumioui/ui";
import { useSafeAreaInsets } from "react-native-safe-area-context";
/**
* StoriesViewer — PRO component. Full-screen story viewer with segmented
* progress bars, left/right tap zones to navigate, hold-to-pause via
* long-press, and swipe-down to dismiss. Requires a dev build
* (react-native-gesture-handler).
*/
export interface Story {
id: string;
/** Remote or local image. Ignored when `content` is provided. */
image?: ImageSourcePropType | { uri: string };
/** Custom rendered story body (takes precedence over image). */
content?: React.ReactNode;
/** Per-story duration in ms. Default 5000. */
duration?: number;
}
export interface StoriesViewerProps extends Omit<ViewProps, "className"> {
open: boolean;
stories: Story[];
/** Controlled index of the visible story. Omit (with defaultIndex) for uncontrolled. */
index?: number;
/** Uncontrolled initial story. Default 0. */
defaultIndex?: number;
onIndexChange?: (index: number) => void;
onClose: () => void;
className?: string;
}
interface SegmentProps {
/** -1 = unfilled, 0 = filling, 1 = filled */
state: "filled" | "active" | "empty";
progress: SharedValue<number>;
}
function Segment({ state, progress }: SegmentProps) {
const fillStyle = useAnimatedStyle(() => ({
transform: [{ scaleX: state === "active" ? progress.value : state === "filled" ? 1 : 0 }],
}));
return (
<View className="h-0.5 flex-1 overflow-hidden rounded-full bg-overlay">
<Animated.View
style={[
{
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
// White-on-media exception: segments sit on full-bleed story imagery.
backgroundColor: "#FFFFFF",
transformOrigin: "left",
},
fillStyle,
]}
/>
</View>
);
}
export const StoriesViewer = forwardRef<View, StoriesViewerProps>(function StoriesViewer(
{
open,
stories,
index: controlledIndex,
defaultIndex = 0,
onIndexChange,
onClose,
className,
...props
}: StoriesViewerProps,
ref
) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const reduce = useReducedMotion();
const progress = useSharedValue(0);
const translateY = useSharedValue(0);
// Entrance fade — the Modal itself is animationType="none" so this
// Reanimated opacity layer is the only enter transition.
const openOpacity = useSharedValue(0);
// Keep the Modal mounted while the exit fade runs — visible={open} alone
// would unmount the whole tree mid-animation (same convention as sheets).
const [rendered, setRendered] = React.useState(open);
const [uncontrolledIndex, setUncontrolledIndex] = React.useState(defaultIndex);
// 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 ?? 5000;
const goTo = React.useCallback(
(next: number) => {
if (next < 0 || next >= stories.length) {
onClose();
return;
}
haptics.selection();
if (controlledIndex !== undefined) {
onIndexChange?.(next);
} else {
setUncontrolledIndex(next);
}
},
[stories.length, controlledIndex, onIndexChange, onClose, haptics]
);
const goToRef = React.useRef(goTo);
goToRef.current = goTo;
// Shared mirrors so worklet completion always advances the CURRENT story
// and resume only runs after a real long-press pause.
const currentIndex = useSharedValue(clamped);
const paused = useSharedValue(false);
React.useEffect(() => {
currentIndex.value = clamped;
}, [clamped, currentIndex]);
// Entrance + exit fade on the content layer (Modal animationType is
// "none"). The exit runs on timing.fast, then `rendered` drops the Modal.
React.useEffect(() => {
if (open) {
setRendered(true);
// A swipe-dismissed viewer reopens centered, not mid-fall.
translateY.value = 0;
openOpacity.value = reduce ? 1 : withTiming(1, timing.normal);
} else if (rendered) {
if (reduce) {
openOpacity.value = 0;
setRendered(false);
} else {
openOpacity.value = withTiming(0, timing.fast, (finished) => {
if (finished) runOnJS(setRendered)(false);
});
// A mid-drag dismissal keeps falling in the direction it was thrown.
if (translateY.value !== 0) {
translateY.value = withTiming(Math.sign(translateY.value) * 600, timing.fast);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, reduce, rendered]);
// Drive the active segment's progress; auto-advance on completion.
// Per-story duration is content data (like video length), not UI motion,
// so it intentionally bypasses the timing tokens — the fill itself still
// runs on the UI thread via a shared value.
React.useEffect(() => {
if (!open || stories.length === 0) {
// Close path: cancel immediately so a finishing callback can't fire
// one frame past close and advance a story that isn't visible.
cancelAnimation(progress);
return;
}
paused.value = false;
if (reduce) {
// Reduced motion: snap the segment filled and never auto-advance —
// navigation stays user-driven.
progress.value = 1;
return;
}
progress.value = 0;
progress.value = withTiming(1, { duration }, (finished) => {
if (finished) runOnJS(goToRef.current)(currentIndex.value + 1);
});
return () => cancelAnimation(progress);
}, [open, clamped, duration, stories.length, progress, currentIndex, paused, reduce]);
// Hold-to-pause: freeze progress, resume with remaining time.
const pause = React.useCallback(() => {
paused.value = true;
cancelAnimation(progress);
}, [progress, paused]);
const resume = React.useCallback(() => {
if (!paused.value) return;
paused.value = false;
if (reduce) {
// Nothing to resume — reduced motion holds the segment filled.
progress.value = 1;
return;
}
const remaining = (1 - progress.value) * duration;
progress.value = withTiming(1, { duration: Math.max(0, remaining) }, (finished) => {
if (finished) runOnJS(goToRef.current)(currentIndex.value + 1);
});
}, [progress, duration, paused, currentIndex, reduce]);
const dismiss = React.useCallback(() => {
haptics.light();
onClose();
}, [haptics, onClose]);
// Rotor access: the tap zones span the whole screen, so pause/resume is
// exposed as named accessibility actions on the root for VoiceOver and
// TalkBack users who can't perform the hold gesture.
const onA11yAction = React.useCallback(
(event: AccessibilityActionEvent) => {
const name = event.nativeEvent.actionName;
if (name === "pauseStory") pause();
else if (name === "resumeStory") resume();
},
[pause, resume]
);
const swipeDown = React.useMemo(
() =>
Gesture.Pan()
.activeOffsetY(20)
.onUpdate((e: { translationY: number }) => {
"worklet";
// Downward tracks 1:1; upward rubber-bands at 1/5 rate — the viewer
// resists being pushed up rather than clamping dead.
translateY.value =
e.translationY >= 0
? e.translationY
: Math.max(-48, e.translationY * 0.2);
})
.onEnd((e: { translationY: number; velocityY: number }) => {
"worklet";
const shouldClose = e.translationY > 140 || e.velocityY > 900;
if (shouldClose) {
// Accelerate away — dismissals leave faster than they arrive.
translateY.value = reduce
? 600
: withTiming(600, { ...timing.fast, easing: Easing.bezier(...ease.in) });
runOnJS(dismiss)();
} else {
translateY.value = reduce ? 0 : withSpring(0, spring.surface);
}
}),
[translateY, dismiss, reduce]
);
const closePress = usePressAnimation({ pressedOpacity: 0.6 });
// True once a long-press pause fired — guards `onPress` so releasing a
// hold doesn't also advance the story.
const didLongPress = React.useRef(false);
const tapZone = (dir: -1 | 1) => ({
onPressIn: () => {
didLongPress.current = false;
},
onPress: () => {
if (didLongPress.current) {
didLongPress.current = false;
return;
}
goTo(clamped + dir);
},
onLongPress: () => {
didLongPress.current = true;
pause();
},
onPressOut: resume,
});
const containerStyle = useAnimatedStyle(() => ({
transform: [
{ translateY: translateY.value },
// Shrinks slightly as it travels — the iOS stories "card recedes as
// you pull it away" feel. Reduced motion drops the scale.
{ scale: reduce ? 1 : interpolate(translateY.value, [0, 500], [1, 0.92], "clamp") },
],
opacity: openOpacity.value * (1 - Math.min(0.5, translateY.value / 800)),
}));
return (
// animationType="none": the entrance fade runs on `openOpacity` above —
// a Reanimated layer consistent with the other overlays in this package.
<Modal visible={rendered} animationType="none" onRequestClose={dismiss}>
<GestureDetector gesture={swipeDown}>
<Animated.View
ref={ref}
// Letterbox black — media surface, not a theme token.
// max-w-[480px] keeps it a phone window on wide docs canvases;
// self-center centers it. No-op on phones.
style={[{ flex: 1, backgroundColor: "#000" }, containerStyle]}
className={cn("w-full max-w-[480px] self-center", className)}
accessibilityActions={[
{ name: "pauseStory", label: "Pause story" },
{ name: "resumeStory", label: "Resume story" },
]}
onAccessibilityAction={onA11yAction}
{...props}
>
{/* Story body */}
<View className="absolute inset-0">
{stories.length === 0 ? (
<View className="flex-1 items-center justify-center px-8">
{/* White-on-media exception: message sits on the black media surface. */}
<Text className="text-center text-base leading-tight text-white">
No stories to show
</Text>
</View>
) : story?.content ? (
story.content
) : story?.image ? (
<Image
source={story.image as ImageSourcePropType}
style={{ width: "100%", height: "100%" }}
resizeMode="cover"
accessibilityIgnoresInvertColors
/>
) : null}
</View>
{/* Progress segments */}
{stories.length > 0 && (
<View
style={{ top: insets.top + 8 }}
className="absolute left-3 right-3 flex-row gap-1"
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>
)}
{/* Close button */}
<AnimatedPressable
onPress={dismiss}
onPressIn={closePress.onPressIn}
onPressOut={closePress.onPressOut}
style={[{ top: insets.top + 20 }, closePress.animatedStyle]}
className="absolute right-4 h-11 w-11 items-center justify-center"
accessibilityRole="button"
accessibilityLabel="Close stories"
hitSlop={8}
>
{/* White-on-media exception: close glyph floats over story imagery. */}
<Icon name="x" size="md" color="#FFF" />
</AnimatedPressable>
{/* Tap zones + hold to pause */}
<View className="absolute inset-0 flex-row" style={{ top: insets.top + 40 }}>
<Pressable
className="flex-1"
{...tapZone(-1)}
delayLongPress={200}
accessibilityLabel="Previous story"
accessibilityHint="Touch and hold to pause the story"
accessibilityRole="button"
/>
<Pressable
className="flex-1"
{...tapZone(1)}
delayLongPress={200}
accessibilityLabel="Next story"
accessibilityHint="Touch and hold to pause the story"
accessibilityRole="button"
/>
</View>
</Animated.View>
</GestureDetector>
</Modal>
);
});
StoriesViewer.displayName = "StoriesViewer";