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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** 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

function subscribeReducedMotion(callback: () => void) {
  const mq = window.matchMedia("(prefers-reduced-motion: reduce)")

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
- 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: set a direction sign, render the top card at translate(±130%, -6%)
  rotate(±18deg) opacity 0, and advance the index in the card's
  onTransitionEnd (guarded on propertyName === "transform") — animation-driven
  state, no timers to clear.
- 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, not on a timer, so the data change and the visual exit can never drift apart.
  • 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