Stories

Free · MITReact Native · Expo

An 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.

View as Markdown
Try it on your phone
Scan to open this demo in your phone's browser and use it with your fingers.

Installation

$ npx shadcn@latest add https://goodui.dev/native/r/stories.json

Then 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

Use it for
  • 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.
Reach for something else
  • 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

PropTypeDefault
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.
number0
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.
number5000
haptic
`false` silences the haptic on close.
booleantrue
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.