Stories Viewer
A story-format viewer — one segmented progress bar per item, tap zones for previous/next, press-and-hold to pause, and auto-advance across image and video stories.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/stories-viewer.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "StoriesViewer" component (React, lucide-react
icons and a cn() class merger — no other runtime dependencies).
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>. The
forwarded ref points at the frame, which is the focusable element, so an owner can
hand focus to the viewer after opening it.
- StoryItem = { id: string; type?: "image" | "video"; src: string; alt: string;
durationMs?: number; caption?: string; poster?: string }. id is the identity behind
the React key, the media remount and the adopted-duration cache.
- items: StoryItem[] — the whole run, in order.
- index? / defaultIndex = 0 / onIndexChange? — controlled or uncontrolled position.
A controlled value is clamped into range, never wrapped, so an out-of-range index
still lands on a real story.
- elapsedMs? — the driven clock: milliseconds into the current segment. Supplied, the
component starts no ticker at all, renders exactly that instant, and leaves the
segment boundary to whoever owns the timeline. That is what makes tests and
screenshots deterministic.
- paused? / onPausedChange? — controlled or uncontrolled pause. Press-and-hold is
separate internal state and never writes here.
- defaultDurationMs = 5000, loop = false, onComplete?, onClose?, header?: ReactNode,
emptyLabel = "No stories to show.", aria-label = "Stories".
Behavior
- Segment length = item.durationMs ?? the duration adopted from a video's
loadedmetadata event ?? defaultDurationMs, floored at 1ms so the division can never
blow up. Progress ratio = min(1, elapsed / duration). Bars before the active index
render 1, bars after it 0, the active one renders the ratio.
- Internal clock: one state object { elapsed, index }. Reading it back only when its
index still matches the active one makes "a move restarts the segment" a derived
fact — no effect has to notice the change and reset anything, which also keeps the
component free of setState-in-effect.
- Own ticker (only when elapsedMs is absent): a requestAnimationFrame loop holds one
local value for the life of a segment, adds each frame delta, and hands off to the
advance at value >= duration. The value is read and written inside the same
callback, so a boundary cannot fire twice while React is still committing the
first one. performance.now() is read in the effect body and in the callback, never
during render. Keep the advance behind a ref updated in an effect so an inline
onIndexChange cannot restart the loop on every render.
- prefers-reduced-motion (subscribed with useSyncExternalStore so it reacts to OS
changes) swaps the frame loop for setInterval steps of duration / 10, floored at
250ms: the bar jumps in whole tenths instead of animating and the story still
advances on its own. The feature survives, the motion does not.
- Advance rules: not the last story, go to index + 1. Last story with loop, go to 0.
Last story without loop, freeze the bar full, set an ended flag through a ref that
is read and written in the same handler, and call onComplete exactly once. Previous
at index 0 restarts the segment instead of doing nothing.
- Pointer: one aria-hidden overlay owns the whole frame. pointerdown captures the
pointer and arms a 220ms timer; if it fires, the viewer pauses and stays paused
until release. On pointerup, a press that was held resumes and navigates nothing; a
press that travelled more than 10px was a scroll and navigates nothing; anything
else is a tap, and a release in the left 32% of the frame's width steps back while
the rest steps forward. pointercancel unwinds the same state. Capturing means the
release still lands on the overlay when the finger slides off the frame.
- A gesture is never the only path: the zones are pointer-only (aria-hidden, not
focusable) precisely because the same commands exist as real buttons — previous,
next, pause/resume/replay, close — and on the keyboard: ArrowLeft previous,
ArrowRight next, Home first, End last, Space toggles pause (ignored when a button
inside the frame holds focus, which owns Space itself), Escape calls onClose and
stops propagating so a surrounding dialog does not also close.
- Video items: the clock owns the timeline and the element follows it. While running,
play() muted (the promise rejection is swallowed — a refused autoplay still times
out on the clock); when paused or driven, pause(). Under a driven clock the element
is scrubbed to elapsed / 1000 whenever that moves more than 0.15s, so a video card
screenshots as deterministically as an image one. Write video.muted in an effect;
React has never synced the attribute reliably.
- Media that fails to load marks its own id broken and renders a labelled fallback
(icon + alt text) instead of a broken image; the segment keeps its slot on the
timeline and its neighbours are untouched.
- The clock stops when nobody is watching: a hidden tab (visibilitychange) and an
off-screen frame (IntersectionObserver) both pause it, so a story never runs out
behind the reader's back. Neither shows the paused chip — they are not the user's
doing.
- items = [] renders the frame at its normal size with emptyLabel inside; no ticker
starts and no chrome pretends to control an empty timeline.
- Cleanup: the rAF is cancelled and the interval cleared on every dependency change
and on unmount, the hold timer is cleared on unmount, the observer is disconnected,
and the video is paused by the effect's own cleanup. Nothing survives the frame.
- onClose fires but the viewer never unmounts itself. The owner decides what closing
means and must hand focus to a deliberate successor.
Rendering & styling
- Frame: relative isolate aspect-[9/16] w-full max-w-xs overflow-hidden rounded-2xl
border bg-muted, focus-visible ring-2 ring-ring ring-offset-2, tabIndex 0,
role="group" with aria-roledescription="Stories viewer" and a data-state of
"playing" | "paused" | "ended" for consumers to style against.
- Layers, bottom to top: media (absolute inset-0 object-cover, keyed by item id, with
a 220ms fade that motion-reduce removes) — the pointer overlay at z-10 — top and
bottom scrims at z-20 (bg-gradient from-background/85 via-background/35 to
transparent), which is what lets every piece of chrome above them use plain
text-foreground and stay legible over any photo in either theme — the status chip
at z-20 — the bar strip, header and controls at z-30.
- Segment bars: a flex row of equal tracks, bg-foreground/25, each holding a
bg-foreground fill with origin-left and transform scaleX(ratio) and no CSS
transition; the value itself is the animation. The strip is aria-hidden on purpose:
a 60fps aria-valuenow is noise, so position is announced once per segment by an
sr-only role="status" aria-live="polite" region reading "Story 2 of 5: caption".
- Controls are rounded-full buttons, bg-background/70 with backdrop-blur, border and
focus-visible ring. Nothing is ever natively disabled: previous restarts at the
first story, next finishes at the last, and the pause button becomes Replay once
the run has ended, so no control dies under the user's focus. The one command that
really does run out — next, after the last story has finished — goes aria-disabled
and dimmed while keeping its focus, with the same ref guard refusing the click.
- Semantic tokens only: bg-muted, bg-background, bg-foreground, text-foreground,
text-muted-foreground, border, ring-ring. cn() merges className into the frame.
- The next story's image is rendered hidden so a tap has nothing to wait for.
Customization levers
- Pace: defaultDurationMs sets the house rhythm, per-item durationMs overrides it,
and a video with neither adopts its own media length. Drop REDUCED_STEPS to 5 for
chunkier stepped progress under reduced motion.
- Gesture feel: HOLD_MS (220) trades hold latency against accidental pauses, TAP_SLOP
(10) against accidental navigation on touch, and PREVIOUS_ZONE (0.32) sizes the
back zone — 0.5 splits the frame evenly, 0 disables tap-back and leaves the button.
- Shape: swap aspect-[9/16] for [4/5] or square, or drop max-w-xs and let a parent
size a full-bleed viewer; the transforms and zones are all ratio-agnostic.
- Chrome: header takes any node (avatar, handle, timestamp) and owns its own pointer
events so a link inside it stays clickable; omit onClose to drop the ✕; move the
previous/next buttons into the header row for a top-heavy layout.
- Ending: loop for an endless reel, or onComplete to route the last frame into "next
user", an upsell or a dismissal — the frozen full bar plus Replay is the default.
- Wiring: onIndexChange is the hook for analytics or a synced caption elsewhere;
elapsedMs plus a controlled index turns the whole component into a pure function of
an injected instant for tests, screenshots and scripted walkthroughs.Concepts
- Injected instant — pass
elapsedMsand the component stops being a clock: it renders the moment you hand it, auto-advance included, so a screenshot, a test and a scripted walkthrough all see the same frame. Omit it and the same rendering path is fed by the component's own ticker. - One clock, three consumers — the bars, the segment boundary and any video read the same elapsed value, so they cannot drift; under a driven clock the video is scrubbed to that instant instead of free-running.
- Hold versus tap — a press is ambiguous until it resolves: 220ms without release makes it a pause, 10px of travel makes it a scroll, and anything else is a tap whose x position picks previous or next.
- Zones are the shortcut, not the interface — the tap surface is
aria-hiddenand unfocusable because every command it offers also exists as a real button and a key, so nothing is reachable by gesture alone. - Stepped progress — with motion reduced the frame loop is replaced by whole-tenth steps: the bar stops animating, the story still advances, and nothing about the run depends on the decoration.
- Decorative bars, one polite announcement — a progress bar updating 60 times a second is noise for a screen reader, so the strip is hidden from the tree and the position is spoken once per segment as "Story 2 of 5".
Product Gallery
A thumbnail rail driving one stage — pointer-tracked and keyboard-pannable magnification, inline video entries with duration chips, and a layout that follows its container.
Swipe Cards
A two-verdict card deck — the top card tilts under the pointer, stamps its verdict as it nears the threshold, commits on distance or flick, and answers to arrow keys, buttons and an undo stack just the same.
