Photo stack
A loose pile of prints. Tap the front one (left or right half) or fling it: it swings out, tucks under the pile and the rest step forward one by one.
Code
1. Install
npm i framer-motion
2. Files
"use client";
// PhotoStack: a loose pile of prints. Tap the front one (left or right half) or fling it: it swings out, tucks under
// the pile and the rest step forward one by one. Enter, Space and the arrow keys do the same.
import { useCallback, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent as RKE, type PointerEvent as RPE } from "react";
import { AnimatePresence, animate, motion, motionValue, useReducedMotion, type MotionValue } from "framer-motion";
export type PhotoStackCue = "grab" | "place" | "fling";
export type Photo = {
src: string;
/** Shown as the caption under the pile. */
alt: string;
};
export type PhotoStackProps = {
/** Photos in the pile, front first. The defaults are Teseria's demo art (/art/*.svg) and only exist in Teseria: pass your own. */
photos?: Photo[];
/** Index of the photo on top (controlled). */
value?: number;
/** Index of the photo on top at mount (uncontrolled). */
defaultValue?: number;
/** Called with the index of the photo that came to the front. */
onChange?: (index: number) => void;
/** Sound hook: "grab" on grab, "place" when a drag springs back, "fling" when a photo is sent to the back. */
onSound?: (cue: PhotoStackCue) => void;
className?: string;
style?: CSSProperties;
};
export const DEFAULT_PHOTOS: Photo[] = [
{ src: "/art/mountain.svg", alt: "Mountain" },
{ src: "/art/dunes.svg", alt: "Dunes" },
{ src: "/art/bloom.svg", alt: "Bloom" },
{ src: "/art/tide.svg", alt: "Tide" },
{ src: "/art/ember.svg", alt: "Ember" },
];
const SNAP = { type: "spring", stiffness: 380, damping: 34, mass: 0.8 } as const;
const EASE = [0.22, 1, 0.36, 1] as const;
const TOSS = { type: "spring", stiffness: 420, damping: 30, mass: 0.7 } as const;
const SWAP = {
initial: { opacity: 0, scale: 0.94, filter: "blur(4px)" },
animate: { opacity: 1, scale: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } },
exit: { opacity: 0, scale: 0.94, filter: "blur(4px)" },
transition: { duration: 0.18 },
} as const;
const clamp = (n: number, a: number, b: number) => Math.min(b, Math.max(a, n));
const rubber = (x: number, lim: number) => lim * Math.tanh(x / lim);
const mod = (a: number, n: number) => ((a % n) + n) % n;
const GAP = 15;
const TILT = [0, -3.4, 2.8, -2.2, 3.6];
const pose = (d: number, i: number) => ({ x: 0, y: -GAP * d, r: d ? TILT[i % TILT.length] : 0, s: 1 - 0.05 * d });
type Pose = { x: MotionValue<number>; y: MotionValue<number>; r: MotionValue<number>; s: MotionValue<number> };
const mkPose = (d: number, i: number): Pose => {
const p = pose(d, i);
return { x: motionValue(p.x), y: motionValue(p.y), r: motionValue(p.r), s: motionValue(p.s) };
};
const toPose = (m: Pose, d: number, i: number, t: object) => {
const p = pose(d, i);
animate(m.x, p.x, t);
animate(m.y, p.y, t);
animate(m.r, p.r, t);
animate(m.s, p.s, t);
};
/** pointer velocity over the last ~90ms, in px per 60fps frame; 0 if the pointer has rested */
function makeTracker() {
const s: { t: number; x: number; y: number }[] = [];
return {
reset() {
s.length = 0;
},
push(x: number, y: number) {
const t = performance.now();
s.push({ t, x, y });
while (s.length > 2 && t - s[0].t > 90) s.shift();
},
vel() {
if (s.length < 2 || performance.now() - s[s.length - 1].t > 70) return { x: 0, y: 0 };
const a = s[0];
const b = s[s.length - 1];
const f = Math.max(8, b.t - a.t) / 16.67;
return { x: (b.x - a.x) / f, y: (b.y - a.y) / f };
},
};
}
type Fly = { i: number; dir: number; vx: number; vy: number };
export function PhotoStack({ photos = DEFAULT_PHOTOS, value, defaultValue = 0, onChange, onSound, className, style }: PhotoStackProps) {
const n = photos.length;
const [inner, setInner] = useState(defaultValue);
const front = n ? mod(value ?? inner, n) : 0;
const [fly, setFly] = useState<Fly | null>(null);
const frontRef = useRef(front);
frontRef.current = front;
const flyRef = useRef(-1); // the card mid-toss: the depth springs leave it alone
const still = useRef(false);
still.current = !!useReducedMotion();
const [tr] = useState(makeTracker);
const mvs = useRef<Pose[]>([]);
const mv = useCallback((i: number) => (mvs.current[i] ??= mkPose(mod(i - frontRef.current, n), i)), [n]);
const g = useRef({ on: false, moved: false, x0: 0, y0: 0, side: 1 });
const send = (dir: number, vx = 0, vy = 0) => {
if (n < 2) return;
const top = frontRef.current;
const next = mod(top + 1, n);
frontRef.current = next;
flyRef.current = top;
setInner(next);
onChange?.(next);
setFly({ i: top, dir, vx, vy });
onSound?.("fling");
};
// every card not mid-toss springs to its new depth; cards at rest ripple forward front to back, moving ones just retarget
useEffect(() => {
for (let d = 0; d < n; d++) {
const i = mod(front + d, n);
if (i === flyRef.current) continue;
const m = mv(i);
toPose(m, d, i, still.current ? { duration: 0 } : { ...SNAP, delay: m.y.isAnimating() ? 0 : d * 0.03 });
}
}, [front, n, mv]);
useEffect(
() => () => {
for (const m of mvs.current) [m.x, m.y, m.r, m.s].forEach((v) => v.stop());
},
[],
);
// the tossed card swings out in the throw's direction, then (z dropped) tucks under the pile
useEffect(() => {
if (!fly) return;
const m = mv(fly.i);
let back = false;
const tuck = () => {
if (back) return;
back = true;
if (flyRef.current === fly.i) flyRef.current = -1;
toPose(m, mod(fly.i - frontRef.current, n), fly.i, still.current ? { duration: 0 } : SNAP);
};
if (!still.current) {
animate(m.x, fly.dir * 92, { ...TOSS, velocity: fly.vx * 60 });
animate(m.y, 4, { ...TOSS, velocity: fly.vy * 60 });
animate(m.r, fly.dir * 15, TOSS);
animate(m.s, 0.92, TOSS);
}
const t = window.setTimeout(
() => {
tuck();
setFly((f) => (f === fly ? null : f));
},
still.current ? 0 : 180,
);
return () => {
window.clearTimeout(t);
tuck(); // a newer toss replaced this one: send this card home now
};
}, [fly, n, mv]);
const down = (e: RPE<HTMLDivElement>) => {
if (e.button !== 0 || !n) return;
const f = mv(frontRef.current);
e.currentTarget.setPointerCapture(e.pointerId);
const x = e.clientX - e.currentTarget.getBoundingClientRect().left;
g.current = { on: true, moved: false, x0: e.clientX, y0: e.clientY, side: x > e.currentTarget.clientWidth / 2 ? 1 : -1 };
tr.reset();
tr.push(e.clientX, e.clientY);
[f.x, f.y, f.r].forEach((v) => v.stop());
animate(f.s, 0.96, { duration: 0.22, ease: EASE });
};
const move = (e: RPE<HTMLDivElement>) => {
const c = g.current;
if (!c.on) return;
const f = mv(frontRef.current);
const dx = e.clientX - c.x0;
const dy = e.clientY - c.y0;
if (!c.moved && Math.hypot(dx, dy) < 4) return;
if (!c.moved) {
c.moved = true;
onSound?.("grab");
}
tr.push(e.clientX, e.clientY);
f.x.set(rubber(dx, 120));
f.y.set(rubber(dy, 60) * 0.5);
f.r.set(rubber(dx, 120) * 0.08);
};
const up = () => {
const c = g.current;
if (!c.on) return;
c.on = false;
const f = mv(frontRef.current);
if (!c.moved) return send(c.side);
const v = tr.vel();
const dx = f.x.get();
if (Math.abs(dx) > 64 || Math.abs(v.x) > 6) return send(Math.sign(Math.abs(v.x) > 2 ? v.x : dx) || 1, v.x, v.y);
onSound?.("place");
[f.x, f.y, f.r].forEach((m) => animate(m, 0, SNAP));
animate(f.s, 1, SNAP);
};
const key = (e: RKE) => {
const dir = { Enter: 1, " ": 1, ArrowRight: 1, ArrowLeft: -1 }[e.key];
if (!dir) return;
e.preventDefault();
send(dir);
};
const lead = photos[front];
const pad = (k: number) => String(k).padStart(2, "0");
return (
<div className={className ? `pho-root ${className}` : "pho-root"} style={style}>
<div
className="pho-stack"
role="button"
tabIndex={0}
aria-label={lead ? `${lead.alt}. Send to the back` : "Photo stack"}
onKeyDown={key}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
>
{photos.map((a, i) => {
const d = mod(i - front, n);
const m = mv(i);
return (
<motion.div key={a.src} className="pho-card" style={{ x: m.x, y: m.y, rotate: m.r, scale: m.s, zIndex: fly?.i === i ? n + 1 : n - d }}>
<img src={a.src} alt="" draggable={false} />
<span className="pho-veil" style={{ opacity: Math.min(d, 4) * 0.13 }} />
</motion.div>
);
})}
</div>
{lead && (
<div className="pho-foot">
<span className="pho-cap" aria-live="polite">
<span className="pho-name">
<AnimatePresence initial={false}>
<motion.b key={lead.src} {...SWAP}>
{lead.alt}
</motion.b>
</AnimatePresence>
</span>
<i>
{pad(front + 1)} / {pad(n)}
</i>
</span>
</div>
)}
</div>
);
}
/* PhotoStack. Prefix pho-. */
.pho-root, .pho-root * { box-sizing: border-box; }
.pho-root {
position: relative;
flex: none;
height: 260px;
width: 280px;
margin: 0 auto;
color: var(--ts-fg, #14161b);
font-family: var(--ts-font, system-ui, sans-serif);
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
.pho-stack {
position: absolute;
top: 54px;
left: 74px;
height: 176px;
width: 132px;
border-radius: var(--ts-r-inner);
cursor: grab;
}
.pho-stack:active { cursor: grabbing; }
.pho-stack:focus-visible {
outline: 2px solid var(--ts-focus, #3446eb);
outline-offset: 2px;
}
.pho-card {
position: absolute;
inset: 0;
border-radius: var(--ts-r-inner);
background: color-mix(in srgb, var(--ts-fg) 9%, var(--ts-raised));
box-shadow: var(--ts-edge);
overflow: hidden;
}
.pho-card img {
display: block;
object-fit: cover;
height: 100%;
width: 100%;
pointer-events: none;
}
.pho-veil {
position: absolute;
inset: 0;
pointer-events: none;
background: var(--ts-tint-1);
}
.pho-foot {
position: absolute;
bottom: 2px;
inset-inline: 40px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
}
.pho-cap {
display: flex;
column-gap: 8px;
align-items: center;
font-size: 13px;
}
.pho-name {
position: relative;
height: 18px;
width: 66px;
}
.pho-name > * {
position: absolute;
top: 0;
left: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
line-height: 18px;
letter-spacing: -.01em;
}
.pho-cap i {
font-style: normal;
font-size: 12px;
color: var(--ts-fg-muted);
font-variant-numeric: tabular-nums;
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { PhotoStack } from "./PhotoStack";
import "./PhotoStack.css";
export default function Demo() {
return <PhotoStack />;
}