Media

Photo Stack

A tilted deck of photos — drag, click or arrow-key the top one away and it cycles to the bottom, forever.

Preview in your theme

Loading preview…

Registry dependencies
utils, use-prefers-reduced-motion
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"

/** render at most 3 layers: anything deeper is fully covered, so rendering it only burns DOM nodes and image requests. */
const MAX_VISIBLE = 3
/** drag past this many pixels to count as a fling; anything less springs back. */
const DRAG_THRESHOLD = 64
/** minimum travel before a gesture counts as a drag rather than a click. */
const CLICK_SLOP = 4
/** backstop for a fling: the duration-300 flight plus a margin, because transitionend can go missing (hidden tab, interrupted transition). */
const FLING_BACKSTOP_MS = 300 + 160

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/photo-stack.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "PhotoStack" component (no runtime
dependencies beyond React and a cn() class merger).

Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
  registryDependencies) — `usePrefersReducedMotion()` switches off the drag
  follow and the fling. If rebuilding without it: a useSyncExternalStore over
  matchMedia("(prefers-reduced-motion: reduce)") with a "change" listener and a
  server snapshot of false.
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- images: { src: string; alt: string; caption?: string }[].
- spread = 1 — multiplier on the per-depth rotation and offset; 0 renders a
  perfectly aligned pile.
- direction = "left" | "right" | "auto" — which way the deck fans and which
  way a click/keyboard fling exits. "auto" alternates the fan side per depth
  and alternates the fling side by the current index.
- index?: number + onIndexChange?: (index: number) => void — controlled top
  photo; uncontrolled otherwise. Normalize with ((i % n) + n) % n so an out of
  range controlled value still lands on a real photo.
- showCounter = true — the "2 / 6" pill.

Behavior
- Render at most 3 cards (top + 2 behind). Deeper cards are fully occluded, so
  rendering them only costs DOM nodes and image requests; the visible window
  is Math.min(images.length, 3) starting at the active index and wrapping,
  which is what makes the deck infinite without duplicating data.
- Per-depth resting transform: translate(side * depth * 7 * spread px,
  depth * 6px) rotate(side * depth * 4 * spread deg) scale(1 - depth * 0.05),
  with zIndex descending by depth. Depth 0 sits square and unrotated.
- Drag: on the top card's pointerdown call setPointerCapture so the gesture
  keeps tracking outside the card; keep the live dx in BOTH a ref (for the
  pointerup threshold test, which must not depend on a rendered frame) and
  state (for the follow transform, translate(dx px) rotate(dx / 24 deg)).
  Past a ~64px threshold the release commits a fling; below it dx resets to 0
  and the card springs back through the same transition.
- Fling: store { side, index } for the card in flight, render the top card at
  translate(±130%, -6%) rotate(±18deg) opacity 0, and advance the index in the
  card's onTransitionEnd (guarded on nativeEvent.propertyName === "transform";
  React fills in the synthetic propertyName on transitionend only, and the
  same handler also serves transitioncancel). transitionend can go missing,
  so three guards keep the deck from locking:
  (1) when the active index no longer matches the stored one (a controlled
  parent moved it mid-flight), clear the fling during render so the new top
  card mounts in its resting pose; (2) onTransitionCancel finishes the fling
  only while reduced motion is on (switching it on mid-flight drops the
  transition), because any other cancel is a new transition replacing a
  running spring-back; (3) a ~460ms timeout (300ms flight + margin), cleared
  whenever the fling ends, finishes it if no event arrives.
- Transitions are attached to the TOP card only. That is deliberate: once the
  flung card drops to a lower depth it has no transition, so it snaps to its
  resting position instead of flying back across the frame.
- Click and keyboard: the top card is a real <button type="button"> — Enter /
  Space advance for free, ArrowRight advances (with the fling), ArrowLeft
  steps back instantly. A pointer drag is followed by a synthetic click, so
  track a "moved more than a few px" ref and swallow that one click.
- Advancing replaces the top card with a different button element, so the old
  one unmounts and focus would fall back to <body> — press ArrowRight once and
  the second press would do nothing. Right before committing an index change,
  record whether the top card currently holds focus; in an effect keyed on the
  active index, move focus to the new top card if it did. Mouse users are
  unaffected because their click may not focus the button at all.
- prefers-reduced-motion (subscribed via useSyncExternalStore so it reacts to
  OS changes): no follow transform, no fling animation — a click, key or
  past-threshold drag switches photos instantly. The feature never depends on
  the animation.
- Degenerate input: 0 photos renders nothing; 1 photo renders a plain
  non-interactive card (never a button that does nothing when clicked).

Rendering & styling
- Root: role="group" aria-roledescription="Photo stack" with an sr-only
  aria-live="polite" region announcing "Photo 3 of 6: <alt>". Every card below
  the top is aria-hidden with an empty alt; the top button's aria-label
  carries position + alt + what activating it does.
- Card frame: absolute inset-0 rounded-xl border bg-card p-2 shadow-lg with an
  inner img size-full rounded-lg object-cover — a "print with a white margin"
  look built from bg-card, so it inverts correctly in dark mode.
- The stage is a relative aspect-[4/3] box; the caption and counter live below
  it (text-muted-foreground, border + bg-card pill, tabular-nums).
- touch-pan-y + select-none on the draggable card so vertical page scrolling
  still works on touch while horizontal drags belong to the deck;
  draggable={false} + pointer-events-none on the img so the browser's native
  image drag never hijacks the gesture.
- Semantic tokens only (bg-card, border, text-muted-foreground, ring-ring);
  focus-visible:ring-2 ring-offset-2 on the top card.

Customization levers
- Deck feel: spread scales rotation and offset together; direction picks the
  fan side. spread 0 + direction "right" gives a neat right-leaning pile.
- Depth: raise the max visible count to 4–5 for a thicker deck (each extra
  layer costs an image request), or lower the per-depth scale step for a
  flatter stack.
- Fling physics: the 300ms duration, the ±130% exit distance and the 18deg
  exit rotation are the three knobs; keep the exit distance past 100% so the
  card fully clears the frame.
- Threshold: ~64px suits mouse and touch; go lower for small cards, higher if
  the stack sits inside a horizontally scrollable area.
- Frame: drop the p-2 for a borderless photo, or swap the aspect-[4/3] stage
  for square (Polaroid) or 3/4 (portrait) — the transforms are ratio-agnostic.
- Wiring: onIndexChange is the hook for analytics, syncing a caption elsewhere
  or driving external prev/next buttons; the component never fetches anything.

Concepts

  • Depth-limited deck — only three cards exist at a time; the window slides over the array with a modulo, so a 60-photo album costs the same DOM as a 3-photo one and the deck can never run out.
  • Pointer capture — capturing the pointer on the card means the drag survives leaving the element (or the window), and the browser releases it automatically on pointerup, so there is no global listener to clean up.
  • Threshold fling vs spring-back — the release decides: past the threshold the card commits and exits, below it the same transition carries it home; a single dx drives both outcomes.
  • Animation-driven commit — the index advances in transitionend, so the data change and the visual exit stay in step; a short timeout backstop only fires when the event never arrives, and an index change from outside abandons the flight instead of freezing it.
  • Top-card-only transition — the flung card loses its transition the instant it drops a layer, which is what lets it reappear at the bottom of the deck without visibly flying back.
  • Click-after-drag suppression — a pointer drag ends with a synthetic click; a "moved" flag swallows exactly one, otherwise every drag would advance the deck twice.
  • Focus follows the deck — each advance mounts a new top button, so focus is carried over deliberately; without it a keyboard user's second arrow press would land on nothing.
  • Reduced-motion parity — with animation off there is no follow and no fling: click, keyboard and past-threshold drags all switch instantly, and the counter and live region keep working.

On This Page