Stories
Free · MITReact Native · ExpoAn Instagram-style story viewer that grows out of the tapped avatar: segmented bars fill at the top, tap right or left to step, hold to pause while the chrome steps aside, swipe sideways to the next person with a plain slide, and drag down to send it back into the avatar.
Installation
$ npx shadcn@latest add https://goodui.dev/native/r/stories.jsonThen run npx expo install --fix so react-native-gesture-handler, react-native-reanimated, react-native-svg match your Expo SDK. Files land in components/goodui-native/stories.
Usage
import { useRef, useState, type ComponentRef } from "react";
import { View } from "react-native";
import { Avatar } from "@/components/goodui-native/avatar/avatar";
import { Stories, type StoryUser } from "@/components/goodui-native/stories/stories";
const users: StoryUser[] = [
{
id: "maya",
name: "Maya Patel",
avatar: "https://example.com/maya.jpg",
stories: [
{ id: "m1", source: "https://example.com/s1.jpg", alt: "Mountain ridge", time: "2h", caption: "Made it to the top" },
{ id: "m2", source: "https://example.com/s2.jpg", alt: "Tents in the snow", time: "1h" },
],
},
];
export function StoryTray() {
const [open, setOpen] = useState(false);
const [seen, setSeen] = useState<string[]>([]);
const origin = useRef<ComponentRef<typeof View> | null>(null);
const allSeen = users[0].stories.every((s) => seen.includes(s.id));
return (
<>
<Avatar
ref={origin}
name={users[0].name}
source={users[0].avatar}
size="lg"
story={allSeen ? "seen" : "unseen"}
onPress={() => setOpen(true)}
/>
<Stories
users={users}
open={open}
onClose={() => setOpen(false)}
seen={seen}
onStoryView={(_, id) => setSeen((s) => [...s, id])}
originRef={origin}
/>
</>
);
}When to use
- Short-lived updates from people or brands: social apps, community apps, creator updates.
- Onboarding or release highlights that play one card after another and can be skipped.
- Daily recaps: a week of workouts, a trip, order updates shown as cards.
- Browsing photos at the person's own pace with zoom: use image-viewer.
- Swipeable cards inside a screen: use carousel.
- Required onboarding steps with inputs: use a normal screen flow.
Examples
A text card instead of a photo
{ id: "recap", content: <WeekCard km={42} />, duration: 4000, time: "5h" }Start at the person that was tapped
<Stories users={users} open={open} initialUser={tapped} onClose={() => setOpen(false)} originRef={originRef} />API reference
| Prop | Type | Default |
|---|---|---|
| users* People with stories, in the order the viewer moves through them. | StoryUser[] | — |
| open* Shows the viewer. It closes itself through `onClose`. | boolean | — |
| onClose* Called when the person closes the viewer: the close button, a swipe down, Escape, or the end of the last story. | () => void | — |
| initialUser Index into `users` to start with. Default 0. | number | 0 |
| seen Ids of stories already seen. Each person starts at their first unseen story. | string[] | — |
| onStoryView Called when a story starts showing. Mark it seen here. | (userId: string, storyId: string) => void | — |
| originRef The view the viewer grows out of and returns into, for example the tapped Avatar. | RefObject<ComponentRef<typeof View> | null> | — |
| duration Default time per story in milliseconds. Default 5000. | number | 5000 |
| haptic `false` silences the haptic on close. | boolean | true |
| labels Overrides for the built-in strings; a GooduiLocaleProvider pack sets them app-wide. | Partial<StoriesLabels> | — |
Motion and haptics
- Open: the viewer grows out of originRef's box (scale, position and a rounder corner) on the smooth spring while the backdrop fades in; without an origin it rises 40 pt from 0.94 scale.
- The current bar fills linearly over the story's duration on the UI thread, starting once a photo has loaded; finished bars are full, upcoming ones empty.
- Touch down pauses at once; after a 220 ms hold the bars, header and caption fade out, and they return on release.
- A sideways drag moves between people 1:1 (a plain slide, no cube) and settles on the smooth spring with the release velocity; the chrome fades while the pages are between people.
- Dragging down moves and shrinks the story and fades the backdrop; past 120 pt (or a fast flick) it closes back into the origin, otherwise it springs back.
- Photos fade in once loaded, over a spinner.
- Reduced motion: opening and closing fade, switching people is a cut, photos appear without fading. The bars still fill because they show time, and a drag still follows the finger.
- Haptic: tap when the viewer closes from the close button, a swipe down, Escape or the end of the last story (haptic={false} turns it off)
- Progress, pause and resume, swipes and the dismiss drag run as worklets; React re-renders when the story or person changes.
- Only the current person and the neighbours on each side are mounted, each showing one story.
- Images load as each story comes up; preload upcoming URLs with Image.prefetch if your network is slow.
Accessibility
- The viewer is modal. The story is one adjustable element labelled 'Maya Patel, story 2 of 3' (plus the alt text) with the value 'Story 2 of 3': swipe up or down (increment, decrement) to step; the iOS escape gesture and Android's back button close.
- With a screen reader running (iOS and Android), stories do not advance on their own; each new story is announced with its caption. The web cannot detect a screen reader, so there the pause button and Space stop the timer.
- A pause and play button (aria-pressed on the web) and a close button sit in the header, 44 pt each, so moving content can always be stopped.
- Web keys: right and left arrows step (mirrored in right-to-left layouts), Space pauses, Escape closes.
- Captions sit on a dark overlay card; the header sits on a scrim, so white text keeps its contrast on bright photos.
Notes for AI
- open is controlled: set it true to show, and set it false in onClose. Keep Stories mounted; it animates out before it unmounts its overlay.
- Every user needs at least one story. A story has either source (photo) or content (any view that fills it).
- Pass seen (story ids) so each person starts at their first unseen story, and add ids in onStoryView. Flip the matching Avatar's story prop to 'seen' when all of a person's stories are seen; the ring sweeps.
- originRef should point at the avatar the person tapped (Avatar takes a ref). Set it right before opening.
- Stories are always drawn on the dark palette (media chrome) regardless of the app's color scheme.
- Durations are in milliseconds per story (default 5000); text cards usually want less.
Related
A photo that fades in over initials on a color picked from the name, with a presence dot and a story ring whose accent arc sweeps in when there is something new and away once seen; AvatarGroup stacks people with a +N counter and fans out on tap.
Tap a thumbnail and the photo flies out of it into a full-screen viewer, its crop opening up as it lands; pinch or double-tap to zoom at your fingers, drag to pan, swipe between photos, and drag down to send it back into its thumbnail while the background fades. On the web the wheel zooms toward the pointer.
A snapping card carousel that follows the finger 1:1, shrinks and dims the neighbours, stretches its pagination pill with the drag, and turns that pill into a timer when it autoplays.