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

> Fan carousel styled as a studio swatch deck: portrait leaves pivot on a brass pin and trail each other on springs. Free React and Tailwind code, or HTML.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/riveted-swatch-deck-photo-carousel-that-fans-portraits-round-a-brass-pin).

- Page, with a live preview: https://shadcn.exchange/r/weinc/riveted-swatch-deck-photo-carousel-that-fans-portraits-round-a-brass-pin
- Screenshot: https://shadcn.exchange/shot/weinc/riveted-swatch-deck-photo-carousel-that-fans-portraits-round-a-brass-pin/light.png
- Registry item: https://we.inc/r/riveted-swatch-deck-photo-carousel-that-fans-portraits-round-a-brass-pin.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/riveted-swatch-deck-photo-carousel-that-fans-portraits-round-a-brass-pin.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/riveted-swatch-deck-photo-carousel-that-fans-portraits-round-a-brass-pin.tsx`

```tsx
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>
  );
}
```
