Media
360 Spin Viewer
A turntable image sequence scrubbed by drag, touch and arrow keys — wrapping at both ends, preloaded behind a progress state, and skipping frames that fail.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/spin-viewer.jsonPrompt
Build a React + TypeScript + Tailwind "SpinViewer" component (lucide-react
icons) with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
frames: { src: string; alt?: string }[]; // one object, in shooting order
startIndex?: number; autoSpin?: boolean; sensitivity?: number }.
- Component props = z.infer of the schema plus aspect ("square" | "video" |
"portrait"), onFrameChange?(index), onRetry?, emptyText, errorText,
className and the rest of the div props spread on the root; forwardRef to
the root element.
- sensitivity is turns per full-width drag: 1 means dragging the whole width
of the stage steps through the sequence exactly once, so the gesture feels
the same in a 320px card and a 900px hero.
Behavior
- Four first-class branches: loading (skeleton in the reserved stage box),
empty ("no frames" panel — also used when status is ready with an empty
array), error (alert + "Try again" only when onRetry exists), ready.
- Ready is itself two phases. Phase one preloads EVERY frame through
Image(), start frame first and then round the turn, at most ~6 in flight,
showing a role=progressbar with "settled / total" while it runs. The stage
is not interactive and not focusable until every frame has settled, so the
first drag can never land on a frame that is still downloading. Percentage
is floored, never rounded: 199 of 200 frames must not read as 100%.
- A frame that fails to load is marked failed, not fatal: navigation skips it
in the direction of travel, and the component reports "N of M frames
unavailable" underneath the stage — and only there, so a refetch that keeps
the same frames never prints it under the loading skeleton. Only when
NOTHING loaded does ready fall back to an error panel. The schema rejects
an empty src outright; a blank (whitespace-only) one is marked failed
immediately, because it can never fire load or error.
- One frame index is driven by three inputs: horizontal pointer/touch drag
(pointer capture, one commit per rAF, touch-action pan-y so the page still
scrolls vertically), ArrowLeft/ArrowRight (one frame), PageUp/PageDown (a
quarter turn derived from the frame count), Home/End (first/last frame).
The index wraps at both ends with a true modulo — JS % keeps the sign of
the dividend, so -1 % 24 is -1, not 23.
- autoSpin advances one frame every ~90ms via a time-accumulated rAF loop
(frame-rate independent, dt clamped so a backgrounded tab does not
teleport). It stops permanently at the first pointer, key OR focus on the
stage — focus counts because the slider's value is announced to a screen
reader, and an auto-spin left running would talk over the keyboard user who
just arrived. It also pauses while the stage is off-screen
(IntersectionObserver), and never starts under prefers-reduced-motion —
manual spinning still works there.
- Every listener, observer and animation frame is cancelled on unmount and
before the effect that owns it re-runs. The preloaded Image objects stay
referenced for the life of the sequence: that is what keeps them decoded,
so swapping one <img> src is instant instead of a flash of nothing.
Rendering & styling
- Semantic tokens only: bg-card panels, bg-muted stage, bg-primary progress
fill, text-muted-foreground supporting copy, border/border-dashed, and
bg-background/80 + backdrop-blur chips for the frame counter and the "drag
to spin" hint. No hard-coded colours.
- The stage is role=slider, tabIndex 0, with aria-valuemin 1, aria-valuemax
frames.length, aria-valuenow the 1-based frame and aria-valuetext "Frame 7
of 24" plus that frame's alt when it has one. Never announce an angle in
degrees: the component cannot know the capture is a full, even turn.
- The <img> is decorative (alt="") because the slider carries the accessible
name and value; the chips are aria-hidden for the same reason. The stage
box keeps its aspect ratio from the first paint so nothing shifts on load;
focus-visible ring on the stage; cn() merges className.
Customization levers
- Spin feel: AUTO_SPIN_MS sets the auto-spin rate, sensitivity the drag
ratio; invert the sign of framesPerPixel for captures shot the other way
round so the object still follows the finger.
- Stage crop: aspect square / video / portrait, or swap the class map for a
fixed height; object-contain keeps a product whole, object-cover fills.
- Preload policy: PRELOAD_PARALLEL trades first-frame latency for total
time; drop the gate to "start frame only" for very long sequences and keep
the progress bar as an overlay.
- Chrome: remove the counter or hint chips, move the "unavailable frames"
note into a tooltip, or add prev/next buttons that call the same advance()
the keys use.
- Extras that reuse the index: a hotspot layer keyed by frame, or a
thumbnail rail, both driven from onFrameChange.Concepts
- Preload gate — the sequence is fetched before the stage accepts a gesture, with the progress visible while it happens. A spin that lets you drag into a frame that has not arrived is the failure this state exists to prevent, and the count it shows is the count it waited for.
- One index, three inputs — pointer, touch and keyboard all move the same number; nothing is a second code path with its own rounding, so a drag and an arrow key can never disagree about which frame is current.
- Wrap-around, not clamping — a turntable has no first or last angle, so both ends roll over through a true modulo. That is the whole difference between a spin and a carousel with arrows greyed out at the edges.
- Degrade by skipping — a frame that 404s is stepped over in the direction the finger is already going, and the shortfall is stated plainly underneath rather than left as a silent stutter.
- Auto-spin as an invitation — it exists to show the object is turnable, so it yields permanently at the first touch, key or focus, pauses off-screen, and never runs under reduced motion, where the control keeps working by hand.
Video Chapters
The chapter list beside a player: start times go in, every length, boundary and share of the runtime comes out derived, with the chapter holding the playhead marked, kept in view and filling a progress sliver.
Animated Text
Character-level text animation — typewriter, scramble and flowing gradient as one component's variants.