Skip to content
WANCRAFT

B.189Cards & Containers

Story Viewer

A stories viewer with segmented progress, tap and hold gestures, a cube turn between people and a native <dialog> modal.

NewReactMotionTailwindadvanced

Overview

Story Viewer plays short, full-bleed stories grouped by person. Segmented bars at the top fill as each story runs, then it advances on its own. Tap the right side to skip ahead or the left side to go back, and press and hold to pause, which also hides the chrome so you can look at the picture. Moving to the next person's stories turns the frame like a cube; slide and fade transitions are also available. Captions sit on a scrim at the bottom. Because content moves on its own, there is always a visible pause button (WCAG 2.2.2), Space toggles it, and playback also stops while the tab is hidden or the viewer is scrolled out of view. In modal mode a row of avatar rings, which grey out once seen, opens the viewer in a native <dialog>; swipe down, Escape or the × button closes it and focus returns to the avatar. Story content comes from a render prop, so it can be images, video or anything else.

01Installation

Method 01 · Registry

CLI verified

Uses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/story-viewer.tsx and adds its dependencies.

npx shadcn@latest add https://wancraft.my/r/story-viewer.json

Method 02 · Copy and paste

  1. 1. Install the dependencies below.
  2. 2. Copy the source from the Code tab into components/ui/story-viewer.tsx.
  3. 3. Import it and adjust props.
npm install motion
Dependencies
motion
Peer
react ≥ 18, tailwindcss ≥ 4
Added
13 Oct 2026
Updated
—

02Usage

The default configuration. Change properties above to generate a tailored example in the Usage tab.

example.tsx
import { StoryViewer, type StoryGroup } from "@/components/ui/story-viewer";

export function StoryViewerExample() {
  const groups: StoryGroup[] = [
    {
      id: "studio",
      name: "Studio",
      meta: "2h",
      stories: [
        { id: "s1", caption: "New prints are in.", content: <div className="size-full bg-gradient-to-b from-orange-300 to-rose-500" /> },
        { id: "s2", caption: "Open until 9pm.", content: <div className="size-full bg-gradient-to-b from-sky-300 to-indigo-600" /> },
      ],
    },
    {
      id: "mira",
      name: "Mira",
      meta: "5h",
      stories: [{ id: "m1", caption: "Sunset run.", content: <div className="size-full bg-gradient-to-b from-amber-200 to-orange-600" /> }],
    },
  ];

  return (
    <StoryViewer groups={groups} />
  );
}

03Props

PropTypeDefaultDescription
groupsStoryGroup[]—Required. { id, name, avatar?, meta?, stories: { id, caption?, label?, duration?, content? }[] }[].
renderStory(story, { group, index, paused }) => ReactNode—Renders a story (falls back to story.content). paused lets video pause too.
durationnumber5000Time per story in ms; a story's own duration wins.
modalbooleantrueAvatar row that opens the viewer in a native <dialog>; false renders the viewer inline.
transition"cube" | "slide" | "fade""cube"Transition between people's groups. Stories within a group cut.
showCaptionsbooleantrueShow story captions over a bottom scrim.
loopbooleanfalseStart again after the last story (otherwise the modal closes, inline stops with Replay).
holdDelaynumber200Press length in ms that pauses instead of navigating.
heightnumber480Inline viewer height in px (width follows 9:16).
initialGroupnumber0Group the inline viewer starts on.
onStoryChange(groupIndex: number, storyIndex: number) => void—Called whenever the visible story changes.
onOpenChange(open: boolean) => void—Modal mode: called when the dialog opens or closes.
labelstring"Stories"Accessible name of the viewer and the avatar row.
accentstring"#FF7448"Unseen ring and focus colour.
classNamestring—Extra classes for the root.

04Features

  • 01Segmented progress bars driven by a rAF clock that writes to the DOM, so there are no re-renders per frame
  • 02Auto-advance per story with per-story durations, across people and optionally looping
  • 03Tap the left third to go back and the rest to go forward; press and hold to pause, which also hides the chrome
  • 04Cube, slide or fade transitions between people; stories within a group cut
  • 05Pauses while the tab is hidden or the viewer is scrolled out of view
  • 06Modal mode: avatar rings that grey out once seen, a native <dialog>, swipe down or Escape to close
  • 07Render-prop content: images, video, SVG or any React node
motion · tweenmotion · springinput · clickinput · pressinput · draginput · keyboard

05Accessibility

  • A visible pause / play button is always there (WCAG 2.2.2), and Space or K toggles playback from anywhere in the viewer.
  • The viewer is a focusable region (aria-roledescription "stories"). Arrow Right / Left move between stories and into the next or previous person; Escape closes the modal.
  • The live region that reads "Mira Tan, story 2 of 3" plus the caption is set to aria-live="off" while stories auto-play, so it doesn't chatter. It speaks when you pause or are paused.
  • The tap zones are real buttons ("Previous story" / "Next story") so screen-reader users can navigate without gestures; the story itself is exposed as an image when it has a label.
  • Avatar buttons announce the person, the story count and "seen"; the modal is a native <dialog>, so the page behind it is inert, and focus returns to the avatar that opened it.
  • With prefers-reduced-motion, transitions between people become short fades and the open / close only fades.

06Limitations

  • The cube turn uses CSS 3D transforms; very old browsers fall back to an instant switch.