Riveted Swatch Deck Photo Carousel That Fans Portraits Round a Brass Pin

A colour guide of the kind a studio keeps for its backdrop papers, lying on a pale blue table and seen at a slight tilt. Nine long slips of card are pinned through one brass rivet at the foot. Each carries a portrait shot on that paper, a flat chip of the colour, its name and a roll code. The stacking is honest: the leaves you have already passed sit on top, pushed well to the left, and the ones still to come peek out from underneath on the right. Step forward and the open leaf swings aside while the next slides out, the looser outer leaves arriving a beat late. Gloss slides across each photo as it turns and every shadow stays on the side away from the light. After the last leaf the deck sweeps shut and reopens. On phones the deck is scaled down and the widest leaves run off the edges.

Code

import { useCallback, useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";

/** One leaf per backdrop paper: the colour chip, its name and roll code, and a portrait shot on it. Top of the deck first. */
const leaves = [
  { name: "Marigold", code: "BP 114", chip: "#f5a00c", src: "https://images.pexels.com/photos/11493115/pexels-photo-11493115.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman in a white top and black trousers sitting on a step ladder against an orange backdrop", focus: "38% 50%" },
  { name: "Fern", code: "BP 207", chip: "#3d8a3c", src: "https://images.pexels.com/photos/12104261/pexels-photo-12104261.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Man in black with a silver chain standing against a green backdrop", focus: "55% 50%" },
  { name: "Poppy", code: "BP 031", chip: "#e0362c", src: "https://images.pexels.com/photos/32174989/pexels-photo-32174989.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman holding out a pleated metallic skirt against a red backdrop", focus: "52% 50%" },
  { name: "Lagoon", code: "BP 162", chip: "#12aebf", src: "https://images.pexels.com/photos/33231184/pexels-photo-33231184.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman in yellow heart shaped sunglasses with her arms raised against a turquoise backdrop", focus: "55% 50%" },
  { name: "Cobalt", code: "BP 188", chip: "#1d50b6", src: "https://images.pexels.com/photos/30755172/pexels-photo-30755172.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Person in pale blue crouching against a deep blue backdrop", focus: "45% 50%" },
  { name: "Canary", code: "BP 102", chip: "#fdd31d", src: "https://images.pexels.com/photos/35511773/pexels-photo-35511773.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman in a grey check blazer leaning on a bright yellow wall", focus: "62% 50%" },
  { name: "Apricot", code: "BP 046", chip: "#f08a55", src: "https://images.pexels.com/photos/6923372/pexels-photo-6923372.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman in a ruffled orange outfit and red sunglasses against an apricot backdrop", focus: "55% 50%" },
  { name: "Emerald", code: "BP 213", chip: "#0d8748", src: "https://images.pexels.com/photos/32174358/pexels-photo-32174358.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman in a teal suit and pink heels standing beside green curtains", focus: "42% 50%" },
  { name: "Pine", code: "BP 229", chip: "#1e4a47", src: "https://images.pexels.com/photos/4823731/pexels-photo-4823731.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "Woman in a brown blazer standing in front of a dark green painted backdrop", focus: "50% 50%" },
];

const N = leaves.length;
// Design pixels; the scene is scaled as one piece to fit narrow screens.
const SCENE_W = 880;
const SCENE_H = 566;
const PX = SCENE_W / 2; // the pin
const PY = 490;
const LW = 124; // leaf width
const LH = 470; // leaf length
const PIN = 440; // distance from the top of a leaf to its hole
const GAP = 36; // degrees the leaf above the open one is pushed aside
const HOLD_MS = 2300;

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const ease = (t: number) => t * t * (3 - 2 * t);

/** Resting angle of a leaf that is `d` places from the open one (negative: above it, swung left). */
function angleFor(d: number) {
  if (d < 0) {
    const m = -d;
    if (m <= 1) return -GAP * ease(m);
    return -(GAP + 38 * (1 - Math.exp(-(m - 1) * 0.38)));
  }
  return 60 * (1 - Math.exp(-d * 0.34));
}

export default function RivetedSwatchDeck() {
  const reduce = useReducedMotion();
  const wrapRef = useRef<HTMLDivElement>(null);
  const sceneRef = useRef<HTMLDivElement>(null);
  const deckRef = useRef<HTMLDivElement>(null);
  const leafRefs = useRef<(HTMLDivElement | null)[]>([]);
  const sheenRefs = useRef<(HTMLDivElement | null)[]>([]);
  const [active, setActive] = useState(4);
  const sim = useRef({
    c: 4,
    cv: 0,
    target: 4,
    a: new Array<number>(N).fill(0),
    v: new Array<number>(N).fill(0),
    tx: 0,
    ty: 0,
    px: 0,
    py: 0,
    dragging: false,
    moved: 0,
    lastX: 0,
    lastT: 0,
    scale: 1,
    paused: false,
    nextAuto: 0,
    active: 4,
  });

  const go = useCallback((to: number) => {
    const s = sim.current;
    s.target = clamp(to, 0, N - 1);
    s.nextAuto = performance.now() + HOLD_MS * 2;
  }, []);

  useEffect(() => {
    const wrap = wrapRef.current;
    const scene = sceneRef.current;
    const deck = deckRef.current;
    if (!wrap || !scene || !deck) return;
    const s = sim.current;
    let raf = 0;
    let visible = true;
    let last = performance.now();
    s.nextAuto = last + 2400;

    const measure = () => {
      const sc = clamp(wrap.clientWidth / 800, 0.56, 1);
      s.scale = sc;
      wrap.style.height = `${Math.round(SCENE_H * sc)}px`;
      scene.style.transform = `translateX(-50%) scale(${sc.toFixed(4)})`;
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(wrap);

    const tick = (now: number) => {
      const dt = Math.min(0.033, (now - last) / 1000);
      last = now;
      if (reduce) {
        s.c = s.target;
        s.cv = 0;
      } else if (!s.dragging) {
        const acc = 34 * (s.target - s.c) - 10.5 * s.cv;
        s.cv += acc * dt;
        s.c += s.cv * dt;
        if (!s.paused && now > s.nextAuto) {
          // thumb through to the bottom leaf, then let the whole deck sweep back
          s.target = s.target >= N - 1 ? 0 : s.target + 1;
          s.nextAuto = now + (s.target === 0 ? HOLD_MS * 1.5 : HOLD_MS);
        }
      }
      // keep the fan centred over the pin whichever leaf is open
      const bias = (0.5 - s.c / (N - 1)) * -14 + 3;
      const sway = reduce ? 0 : Math.sin(now / 1400) * 0.7;
      for (let i = 0; i < N; i++) {
        const want = angleFor(i - s.c) + bias + sway * (1 + Math.abs(i - s.c) * 0.25);
        if (reduce) {
          s.a[i] = want;
          s.v[i] = 0;
        } else {
          // leaves further from the open one are looser, so they trail behind it
          const k = 150 - Math.min(5, Math.abs(i - s.c)) * 13;
          const acc = k * (want - s.a[i]) - 15 * s.v[i];
          s.v[i] += acc * dt;
          s.a[i] += s.v[i] * dt;
        }
        const el = leafRefs.current[i];
        if (!el) continue;
        const a = s.a[i];
        const r = (a * Math.PI) / 180;
        const lift = clamp(Math.abs(s.v[i]) * 0.06, 0, 10);
        el.style.transform = `translateZ(${((N - i) * 1.6 + lift).toFixed(2)}px) rotate(${a.toFixed(3)}deg)`;
        // the light stays put while the leaf turns, so the shadow is counter-rotated
        const sx = 5 + lift * 0.6;
        const sy = 9 + lift;
        const lx = sx * Math.cos(r) + sy * Math.sin(r);
        const ly = -sx * Math.sin(r) + sy * Math.cos(r);
        el.style.boxShadow = `0 0 0 0.5px rgba(40,34,24,0.22), ${lx.toFixed(1)}px ${ly.toFixed(1)}px ${(16 + lift * 2).toFixed(0)}px rgba(30,28,40,0.2), ${(lx * 0.2).toFixed(1)}px ${(ly * 0.2).toFixed(1)}px 2px rgba(30,28,40,0.22)`;
        const sh = sheenRefs.current[i];
        if (sh) sh.style.backgroundPosition = `${(50 + a * 1.7 + s.px * 30).toFixed(1)}% 0`;
      }
      s.px += (s.tx - s.px) * Math.min(1, dt * 6);
      s.py += (s.ty - s.py) * Math.min(1, dt * 6);
      deck.style.transform = `rotateX(${(15 - s.py * 5).toFixed(2)}deg) rotateY(${(s.px * 7).toFixed(2)}deg)`;
      const a = clamp(Math.round(s.c), 0, N - 1);
      if (a !== s.active) {
        s.active = a;
        setActive(a);
      }
      if (visible) raf = requestAnimationFrame(tick);
    };

    const io = new IntersectionObserver(([e]) => {
      const was = visible;
      visible = e.isIntersecting;
      if (visible && !was) {
        last = performance.now();
        raf = requestAnimationFrame(tick);
      }
    });
    io.observe(wrap);
    raf = requestAnimationFrame(tick);

    const down = (e: PointerEvent) => {
      if (e.button !== 0) return;
      s.dragging = true;
      s.moved = 0;
      s.lastX = e.clientX;
      s.lastT = performance.now();
      s.cv = 0;
      wrap.setPointerCapture(e.pointerId);
    };
    const move = (e: PointerEvent) => {
      const rect = wrap.getBoundingClientRect();
      if (e.pointerType === "mouse" && !reduce) {
        s.tx = clamp((e.clientX - rect.left) / rect.width - 0.5, -0.5, 0.5) * 2;
        s.ty = clamp((e.clientY - rect.top) / rect.height - 0.5, -0.5, 0.5) * 2;
      }
      if (!s.dragging) return;
      const now = performance.now();
      const dx = e.clientX - s.lastX;
      const dc = dx / (120 * s.scale);
      s.c = clamp(s.c + dc, -0.35, N - 0.65);
      s.moved += Math.abs(dx);
      const dt = Math.max(1, now - s.lastT) / 1000;
      s.cv = s.cv * 0.6 + (dc / dt) * 0.4;
      s.lastX = e.clientX;
      s.lastT = now;
    };
    const up = (e: PointerEvent) => {
      if (!s.dragging) return;
      s.dragging = false;
      if (s.moved < 6) {
        const hit = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest<HTMLElement>("[data-leaf]");
        if (hit?.dataset.leaf) s.target = Number(hit.dataset.leaf);
      } else {
        s.target = clamp(Math.round(s.c + clamp(s.cv, -8, 8) * 0.2), 0, N - 1);
      }
      s.nextAuto = performance.now() + HOLD_MS * 2;
    };
    const leave = () => {
      s.tx = 0;
      s.ty = 0;
    };
    wrap.addEventListener("pointerdown", down);
    wrap.addEventListener("pointermove", move);
    wrap.addEventListener("pointerup", up);
    wrap.addEventListener("pointercancel", up);
    wrap.addEventListener("pointerleave", leave);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      wrap.removeEventListener("pointerdown", down);
      wrap.removeEventListener("pointermove", move);
      wrap.removeEventListener("pointerup", up);
      wrap.removeEventListener("pointercancel", up);
      wrap.removeEventListener("pointerleave", leave);
    };
  }, [reduce]);

  const onKey = (e: React.KeyboardEvent) => {
    const t = sim.current.target;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      e.preventDefault();
      go(t + 1);
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      e.preventDefault();
      go(t - 1);
    } else if (e.key === "Home") {
      e.preventDefault();
      go(0);
    } else if (e.key === "End") {
      e.preventDefault();
      go(N - 1);
    }
  };
  const pause = (v: boolean) => () => {
    sim.current.paused = v;
    if (!v) sim.current.nextAuto = performance.now() + HOLD_MS;
  };

  const btn = "grid h-10 w-10 place-items-center rounded-full text-slate-700 transition hover:bg-slate-900/10 disabled:opacity-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900";
  const cur = leaves[active];

  return (
    <section className="relative overflow-hidden bg-[#dfe3ec] pb-8 pt-6" onMouseEnter={pause(true)} onMouseLeave={pause(false)} onFocus={pause(true)} onBlur={pause(false)}>
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(70% 80% at 50% 45%, #f1f3f8 0%, #dfe3ec 55%, #c9cedb 100%)" }} />
      <div
        ref={wrapRef}
        role="group"
        aria-roledescription="carousel"
        aria-label="Swatch deck of portraits. Use the left and right arrow keys to thumb through the leaves."
        tabIndex={0}
        onKeyDown={onKey}
        className="relative mx-auto w-full cursor-grab touch-pan-y select-none outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-slate-900/70 active:cursor-grabbing"
        style={{ height: SCENE_H }}
      >
        <div ref={sceneRef} className="absolute left-1/2 top-0" style={{ width: SCENE_W, height: SCENE_H, transformOrigin: "50% 0", transform: "translateX(-50%)", perspective: "1500px", perspectiveOrigin: "50% 40%" }}>
          <div ref={deckRef} className="absolute inset-0" style={{ transformStyle: "preserve-3d", transformOrigin: `${PX}px ${PY}px`, transform: "rotateX(15deg)" }}>
            {/* soft pool of shadow on the table under the pin */}
            <div aria-hidden="true" className="absolute rounded-full" style={{ left: PX - 300, top: PY - 250, width: 600, height: 340, background: "radial-gradient(closest-side, rgba(40,44,70,0.22), rgba(40,44,70,0))", transform: "translateZ(-2px)" }} />
            {leaves.map((l, i) => (
              <div
                key={l.name}
                ref={(el) => {
                  leafRefs.current[i] = el;
                }}
                data-leaf={i}
                className="absolute rounded-[13px] bg-[#fbf9f4]"
                style={{ left: PX - LW / 2, top: PY - PIN, width: LW, height: LH, transformOrigin: `${LW / 2}px ${PIN}px`, zIndex: N - i, willChange: "transform" }}
              >
                <div className="absolute left-[6px] right-[6px] top-[6px] h-[292px] overflow-hidden rounded-[8px] bg-slate-300">
                  <img src={l.src} alt={l.alt} loading="lazy" draggable={false} className="h-full w-full object-cover" style={{ objectPosition: l.focus }} />
                  {/* a band of gloss that slides across the laminate as the leaf turns */}
                  <div
                    ref={(el) => {
                      sheenRefs.current[i] = el;
                    }}
                    aria-hidden="true"
                    className="absolute inset-0"
                    style={{ backgroundImage: "linear-gradient(105deg, rgba(255,255,255,0) 35%, rgba(255,255,255,0.28) 50%, rgba(255,255,255,0) 65%)", backgroundSize: "300% 100%", backgroundPosition: "50% 0" }}
                  />
                  <div aria-hidden="true" className="absolute inset-0 rounded-[8px]" style={{ boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.08)" }} />
                </div>
                <div className="absolute left-[6px] right-[6px] top-[304px] h-[38px] rounded-[4px]" style={{ background: l.chip, boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.08)" }} />
                <div className="absolute left-[9px] right-[9px] top-[350px]">
                  <div className="text-[13px] font-semibold leading-tight tracking-tight text-slate-900">{l.name}</div>
                  <div className="mt-0.5 font-mono text-[8.5px] uppercase tracking-[0.14em] text-slate-600">{l.code}</div>
                </div>
                <div className="absolute left-[9px] top-[392px] font-mono text-[7px] uppercase leading-[1.5] tracking-[0.14em] text-slate-500">
                  2.72 m roll
                  <br />
                  Matte
                </div>
                {/* edge highlight so each leaf reads as a slip of card with thickness */}
                <div aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-[13px]" style={{ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.9), inset 0 -1.5px 0 rgba(120,110,95,0.35)" }} />
              </div>
            ))}
            {/* the brass pin that holds the deck */}
            <div
              aria-hidden="true"
              className="absolute rounded-full"
              style={{
                left: PX - 13,
                top: PY - 13,
                width: 26,
                height: 26,
                zIndex: N + 2,
                transform: "translateZ(22px)",
                background: "radial-gradient(circle at 34% 28%, #fff6cf 0%, #f0cf74 22%, #c8962f 52%, #7c5512 86%, #5b3d0b 100%)",
                boxShadow: "0 3px 5px rgba(30,24,10,0.45), 0 0 0 1px rgba(80,54,10,0.5), inset 0 -2px 3px rgba(60,40,6,0.55)",
              }}
            >
              <div className="absolute left-1/2 top-1/2 h-[11px] w-[11px] -translate-x-1/2 -translate-y-1/2 rounded-full" style={{ background: "radial-gradient(circle at 60% 66%, #f7dc8c, #a8791f 70%)", boxShadow: "inset 0 1px 2px rgba(60,40,6,0.7)" }} />
            </div>
          </div>
        </div>
      </div>

      <div className="relative mx-auto flex max-w-sm items-center justify-between px-4">
        <button type="button" onClick={() => go(sim.current.target - 1)} disabled={active === 0} aria-label="Previous leaf" className={btn}>
          <svg aria-hidden="true" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
            <path d="M12 4l-6 6 6 6" />
          </svg>
        </button>
        <p aria-live="polite" className="flex items-center gap-2.5 text-[13px] font-medium text-slate-800">
          <span aria-hidden="true" className="h-3 w-3 rounded-full" style={{ background: cur.chip, boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.12)" }} />
          {cur.name}
          <span className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-600">{cur.code}</span>
        </p>
        <button type="button" onClick={() => go(sim.current.target + 1)} disabled={active === N - 1} aria-label="Next leaf" className={btn}>
          <svg aria-hidden="true" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
            <path d="M8 4l6 6-6 6" />
          </svg>
        </button>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/riveted-swatch-deck-photo-carousel-that-fans-portraits-round-a-brass-pin.json

When to use it

How to customize it

Accessibility

Questions

Why is the previous leaf covering a corner of the open photo?

Because that is what a pinned deck does. The leaves cannot change their stacking order, so a leaf you have passed stays above the one that is open and overlaps it near the pin. The 36 degree gap keeps that overlap to a small triangle at the foot of the photograph. If you would rather cheat, raise the `zIndex` of the active leaf.

How do the leaves trail behind each other?

There are two layers of spring. One moves a single number, the position of the open leaf. Every leaf then has its own spring that chases the angle it should have for that position, and leaves further from the open one get a softer spring. The result is a fan that bunches and spreads instead of turning as one piece.

How are the shadows kept on the correct side?

A `box-shadow` rotates with its element, which would swing the shadow round as a leaf turns. Each frame the code rotates the shadow offset by the opposite of the leaf angle, so on screen it always falls down and to the right.

Can I pin the deck at the top instead?

Yes. Set `PIN` to about 30, move `PY` near the top of the scene and put the label block above the photograph. The angle maths is the same, though the fan will hang downward and the open leaf reads better if you flip the sign of the spreads in `angleFor`.

Does dragging work on touch screens?

Sideways swipes thumb through the deck and vertical swipes are left to the page through `touch-pan-y`. Tapping any visible leaf opens it. The slight tilt that follows a mouse is not applied for touch input.

Related: Concertina Fold Photo Book Carousel That Opens Flat at the Centre, Photo Proof Roll Carousel That Feeds over a Roller and Curls onto the Table.

More galleries and carousels or all free UI components. Start for free to have a full site built around it.

More in Galleries

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap