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…

"use client"

import * as React from "react"
import { ImageOff, MoveHorizontal, RefreshCw, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"
import type { SpinViewerData } from "./spin-viewer.contract"

/** Milliseconds per frame while auto-spinning — about the speed a hand turns a turntable. */
const AUTO_SPIN_MS = 90
/** A backgrounded tab resumes with one enormous gap; clamp it so the object never teleports. */
const MAX_DT = 250
/** Images requested at once: enough to saturate a connection, few enough that the start frame lands first. */
const PRELOAD_PARALLEL = 6
/** Stage width assumed before the first measurement; only ever scales a drag. */

Installation

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

Prompt

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.

On This Page