Teseria
All elements

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

Terminal
npm i framer-motion

2. Files

PhotoStack.tsx253 lines
"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.css87 lines
/* 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;
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { PhotoStack } from "./PhotoStack";
import "./PhotoStack.css";

export default function Demo() {
  return <PhotoStack />;
}