Media

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, ImageOff, Pause, Play, RotateCcw, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** Cross-fade when the frame swaps stories. React 19 hoists it — no Tailwind config edit. */
const KEYFRAMES = `@keyframes sv-media-in{from{opacity:0}to{opacity:1}}`

/** Segment length for an item that carries no duration of its own. */
const DEFAULT_DURATION_MS = 5000
/** A press held longer than this pauses instead of navigating on release. */
const HOLD_MS = 220
/** Pointer travel that still counts as a tap rather than a scroll or a drag. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/stories-viewer.json

Prompt

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 elapsedMs and 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-hidden and 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".

On This Page