Jigsaw

No dependencies
Code format
React + Tailwind
import { useId, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react';

const CELL = 50;
const GRID = 3;
const PAD = 14;
// Loose pieces: the steps still to do, where they lie, and how they lie.
const LOOSE: Record<number, { task: string; x: number; y: number; r: number }> = {
  2: { task: 'Add a photo', x: 78, y: 0, r: 12 },
  4: { task: 'Verify your email', x: 130, y: 8, r: -10 },
  6: { task: 'Write a short bio', x: 178, y: 14, r: 8 },
};

// One side of a piece from a to b: flat, or a knob (dir 1 out, -1 in) on the classic curve.
function side(ax: number, ay: number, bx: number, by: number, dir: number) {
  if (dir === 0) return `L${bx} ${by}`;
  const dx = bx - ax;
  const dy = by - ay;
  const p = (s: number, h: number) => `${(ax + dx * s + dy * h * dir).toFixed(1)} ${(ay + dy * s - dx * h * dir).toFixed(1)}`;
  return (
    `L${p(0.38, 0)}` +
    `C${p(0.43, 0.06)} ${p(0.33, 0.12)} ${p(0.33, 0.22)}` +
    `C${p(0.33, 0.33)} ${p(0.41, 0.38)} ${p(0.5, 0.38)}` +
    `C${p(0.59, 0.38)} ${p(0.67, 0.33)} ${p(0.67, 0.22)}` +
    `C${p(0.67, 0.12)} ${p(0.57, 0.06)} ${p(0.62, 0)}` +
    `L${bx} ${by}`
  );
}
const across = (c: number, r: number) => ((c + r) % 2 ? 1 : -1);
const down = (c: number, r: number) => ((c + r) % 2 ? -1 : 1);
const PIECES = Array.from({ length: GRID * GRID }, (_, i) => {
  const c = i % GRID;
  const r = Math.floor(i / GRID);
  const x0 = c * CELL;
  const y0 = r * CELL;
  const x1 = x0 + CELL;
  const y1 = y0 + CELL;
  const top = r === 0 ? 0 : -down(c, r - 1);
  const right = c === GRID - 1 ? 0 : across(c, r);
  const bottom = r === GRID - 1 ? 0 : down(c, r);
  const left = c === 0 ? 0 : -across(c - 1, r);
  return {
    i,
    x0,
    y0,
    d: `M${x0} ${y0}${side(x0, y0, x1, y0, top)}${side(x1, y0, x1, y1, right)}${side(x1, y1, x0, y1, bottom)}${side(x0, y1, x0, y0, left)}Z`,
  };
});
const SIZE = CELL * GRID;

const css = `
  .card-jigsaw { flex: none; --sky: light-dark(#dbdde1, #c6c8cd); --sun: light-dark(#fbfcfd, #f1f2f3); --far: light-dark(#6d7179, #7d8087); --near: light-dark(#5f626a, #393c41); --deep: light-dark(#42454c, #232529); position: relative; width: 240px; height: 222px; }
  .card-jigsaw__head { position: absolute; left: 0; top: 0; display: flex; align-items: baseline; gap: 7px; width: 160px; }
  .card-jigsaw__head b { color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 700; letter-spacing: -0.01em; }
  .card-jigsaw__head span { color: light-dark(#5f626a, #a7abb2); font-size: 12px; font-variant-numeric: tabular-nums; }
  .card-jigsaw__board { position: absolute; left: 0; top: 30px; width: ${SIZE}px; height: ${SIZE}px; }
  .card-jigsaw__well { position: absolute; left: 0; top: 0; overflow: visible; }
  .card-jigsaw__slot { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 5%, transparent); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1; stroke-dasharray: 3 3; }
  .card-jigsaw__piece { position: absolute; left: var(--px); top: var(--py); display: block; width: ${CELL + PAD * 2}px; height: ${CELL + PAD * 2}px; padding: 0; border: 0; background: none; overflow: visible; filter: drop-shadow(0 1px 1px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.28))); pointer-events: none; }
  .card-jigsaw__piece svg { display: block; width: 100%; height: 100%; overflow: visible; }
  .card-jigsaw__edge { fill: none; stroke: color-mix(in oklab, var(--sun) 44%, transparent); stroke-width: 0.8; }
  button.card-jigsaw__piece { pointer-events: none; cursor: grab; touch-action: none; filter: drop-shadow(1px 3px 3px light-dark(rgba(0, 0, 0, 0.13), rgba(0, 0, 0, 0.42))); transform: translate(var(--tx), var(--ty)) rotate(var(--tr)); transition: transform 0.5s cubic-bezier(0.3, 1.35, 0.5, 1), filter 0.2s; z-index: 2; }
  button.card-jigsaw__piece svg .card-jigsaw__edge { pointer-events: all; }
  button.card-jigsaw__piece:hover:enabled { filter: drop-shadow(2px 6px 5px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45))); }
  button.card-jigsaw__piece:disabled { z-index: 1; cursor: default; filter: drop-shadow(0 1px 1px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.28))); }
  button.card-jigsaw__piece:disabled svg .card-jigsaw__edge { pointer-events: none; }
  button.card-jigsaw__piece:focus-visible { outline: 2px solid light-dark(#373a40, #f1f2f3); outline-offset: -8px; border-radius: 12px; }
  button.card-jigsaw__piece[data-held='true'] { cursor: grabbing; transition: none; z-index: 5; filter: drop-shadow(4px 10px 7px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45))); }
  button.card-jigsaw__piece:disabled { animation: card-jigsaw-land 0.34s cubic-bezier(0.3, 1.6, 0.5, 1) 0.36s; }
  @keyframes card-jigsaw-land {
    0% { scale: 1.1; }
    100% { scale: 1; }
  }
  .card-jigsaw__sheen { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
  .card-jigsaw__sheen::after { content: ''; position: absolute; top: -20%; bottom: -20%; left: -60%; width: 40%; background: linear-gradient(100deg, transparent, color-mix(in oklab, var(--sun) 66%, transparent), transparent); transform: skewX(-14deg); }
  .card-jigsaw[data-done='true'] .card-jigsaw__sheen::after { animation: card-jigsaw-sheen 1s ease-in-out 0.2s; }
  @keyframes card-jigsaw-sheen {
    to { left: 130%; }
  }
  .card-jigsaw__next { position: absolute; left: 0; right: 0; bottom: 4px; display: flex; align-items: center; gap: 10px; margin: 0; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
  .card-jigsaw__reset { padding: 2px 0; border: 0; background: none; color: light-dark(#373a40, #f1f2f3); font: inherit; font-weight: 600; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
  @media (prefers-reduced-motion: reduce) {
    button.card-jigsaw__piece { transition: none; }
    button.card-jigsaw__piece:disabled { animation: none; }
    .card-jigsaw[data-done='true'] .card-jigsaw__sheen::after { animation: none; }
  }
`;

export function JigsawCard() {
  const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
  const [placed, setPlaced] = useState<number[]>([]);
  const [focus, setFocus] = useState<number | null>(null);
  const [held, setHeld] = useState<{ i: number; dx: number; dy: number } | null>(null);
  const drag = useRef<{ i: number; sx: number; sy: number; moved: boolean; x: number; y: number } | null>(null);
  const order = Object.keys(LOOSE).map(Number);
  const left = order.filter((i) => !placed.includes(i));
  const done = left.length === 0;

  const place = (i: number) => setPlaced((p) => (p.includes(i) ? p : [...p, i]));

  const onDown = (e: ReactPointerEvent<HTMLButtonElement>, i: number) => {
    e.currentTarget.setPointerCapture(e.pointerId);
    drag.current = { i, sx: e.clientX, sy: e.clientY, moved: false, x: 0, y: 0 };
  };
  const onMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
    const d = drag.current;
    if (!d) return;
    const x = e.clientX - d.sx;
    const y = e.clientY - d.sy;
    if (!d.moved && Math.hypot(x, y) < 4) return;
    d.moved = true;
    d.x = x;
    d.y = y;
    setHeld({ i: d.i, dx: x, dy: y });
  };
  const onUp = () => {
    const d = drag.current;
    drag.current = null;
    if (!d) return;
    setHeld(null);
    const l = LOOSE[d.i];
    // A click sends it home; a drag has to end near its slot, or it falls back.
    if (!d.moved || Math.hypot(l.x + d.x, l.y + d.y) < 34) place(d.i);
  };

  const next = focus !== null && left.includes(focus) ? focus : left[0];
  return (
    <>
      <style>
        {css}
      </style>
      <section className="card-jigsaw" aria-label="Profile completion" data-done={done}>
        <svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden="true">
          <defs>
            <g id={`${uid}pic`}>
              <rect width={SIZE} height={SIZE} fill="var(--sky)" />
              <circle cx="104" cy="44" r="20" fill="var(--sun)" />
              <path d="M0 96 L36 56 L62 82 L92 44 L132 92 L150 74 L150 150 L0 150 Z" fill="var(--far)" />
              <path d="M92 44 L104 58 L96 62 L90 54 L82 64 L80 59 Z" fill="var(--sun)" />
              <path d="M36 56 L47 67 L40 70 L35 64 L27 72 L26 67 Z" fill="var(--sun)" />
              <path d="M0 116 C30 96 60 124 92 106 C116 94 132 108 150 100 L150 150 L0 150 Z" fill="var(--near)" />
              <path d="M0 136 C40 122 84 146 150 128 L150 150 L0 150 Z" fill="var(--deep)" />
            </g>
            {PIECES.map((p) => (
              <clipPath key={p.i} id={`${uid}c${p.i}`}>
                <path d={p.d} />
              </clipPath>
            ))}
          </defs>
        </svg>
        <p className="card-jigsaw__head">
          <b>Your profile</b>
          <span>
            {6 + placed.length} of 9
          </span>
        </p>
        <div className="card-jigsaw__board">
          <svg className="card-jigsaw__well" width={SIZE} height={SIZE} aria-hidden="true">
            {order.map((i) => (
              <path key={i} className="card-jigsaw__slot" d={PIECES[i].d} />
            ))}
          </svg>
          {PIECES.map((p) => {
            const loose = LOOSE[p.i];
            const free = loose && !placed.includes(p.i);
            const art = (
              <svg viewBox={`${p.x0 - PAD} ${p.y0 - PAD} ${CELL + PAD * 2} ${CELL + PAD * 2}`} aria-hidden="true">
                <use href={`#${uid}pic`} clipPath={`url(#${uid}c${p.i})`} />
                <path className="card-jigsaw__edge" d={p.d} />
              </svg>
            );
            const slot = { '--px': `${p.x0 - PAD}px`, '--py': `${p.y0 - PAD}px` } as CSSProperties;
            if (!loose) {
              return (
                <span key={p.i} className="card-jigsaw__piece" style={slot}>
                  {art}
                </span>
              );
            }
            const isHeld = held?.i === p.i;
            return (
              <button
                key={p.i}
                type="button"
                className="card-jigsaw__piece"
                aria-label={loose.task}
                data-held={isHeld}
                disabled={!free}
                style={{ ...slot, '--tx': free ? `${loose.x + (isHeld ? held.dx : 0)}px` : '0px', '--ty': free ? `${loose.y + (isHeld ? held.dy : 0)}px` : '0px', '--tr': free && !isHeld ? `${loose.r}deg` : '0deg' } as CSSProperties}
                onPointerDown={(e) => onDown(e, p.i)}
                onPointerMove={onMove}
                onPointerUp={onUp}
                onPointerCancel={() => {
                  drag.current = null;
                  setHeld(null);
                }}
                onPointerEnter={() => setFocus(p.i)}
                onFocus={() => setFocus(p.i)}
                // A keyboard press has no pointer to follow, so it just sends the piece home.
                onClick={(e) => {
                  if (e.detail === 0) place(p.i);
                }}
              >
                {art}
              </button>
            );
          })}
          <span className="card-jigsaw__sheen" aria-hidden="true" />
        </div>
        <p className="card-jigsaw__next" role="status">
          {done ? (
            <>
              Profile complete
              <button type="button" className="card-jigsaw__reset" onClick={() => setPlaced([])}>
                Start over
              </button>
            </>
          ) : (
            <>Next: {LOOSE[next].task}</>
          )}
        </p>
      </section>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/card-jigsaw
Source on GitHub

About this component

Profile completion without a progress bar: each step is a jigsaw piece. Drag the loose ones into the picture, or click to send them home, and the last one finishes it.

Dependencies
None
Difficulty
Advanced
Triggers
card, click, state change, spring
Techniques
clip path, event position, css transition, svg, keyframes
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

Loose pieces are buttons named after their step and pressing one from the keyboard places it. The running count and next step are in the text, and the result is read from a role="status" line. Under prefers-reduced-motion the pieces jump into place.

More cards

See all
PSRW
API keysFour on the ring. Pick one.
Keyring
Ana Kerr, Art director, 020 7946 0112
Rolodex

Reading list · 6 books

Bookshelf
Snow globe