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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/photo-stack.jsonPrompt
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
dxdrives 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.