Jigsaw
Your profile6 of 9
Next: Add a photo
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardJigsaw(el) on the mounted element instead.
<section class="card-jigsaw" aria-label="Profile completion" data-done="false">
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<g id="card-jigsaw-pic">
<rect width="150" height="150" fill="var(--sky)"></rect>
<circle cx="104" cy="44" r="20" fill="var(--sun)"></circle>
<path d="M0 96 L36 56 L62 82 L92 44 L132 92 L150 74 L150 150 L0 150 Z" fill="var(--far)"></path>
<path d="M92 44 L104 58 L96 62 L90 54 L82 64 L80 59 Z" fill="var(--sun)"></path>
<path d="M36 56 L47 67 L40 70 L35 64 L27 72 L26 67 Z" fill="var(--sun)"></path>
<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>
<path d="M0 136 C40 122 84 146 150 128 L150 150 L0 150 Z" fill="var(--deep)"></path>
</g>
<clipPath id="card-jigsaw-c0">
<path d="M0 0L50 0L50.0 19.0C47.0 21.5 44.0 16.5 39.0 16.5C33.5 16.5 31.0 20.5 31.0 25.0C31.0 29.5 33.5 33.5 39.0 33.5C44.0 33.5 47.0 28.5 50.0 31.0L50 50L31.0 50.0C28.5 53.0 33.5 56.0 33.5 61.0C33.5 66.5 29.5 69.0 25.0 69.0C20.5 69.0 16.5 66.5 16.5 61.0C16.5 56.0 21.5 53.0 19.0 50.0L0 50L0 0Z"></path>
</clipPath>
<clipPath id="card-jigsaw-c1">
<path d="M50 0L100 0L100.0 19.0C103.0 21.5 106.0 16.5 111.0 16.5C116.5 16.5 119.0 20.5 119.0 25.0C119.0 29.5 116.5 33.5 111.0 33.5C106.0 33.5 103.0 28.5 100.0 31.0L100 50L81.0 50.0C78.5 47.0 83.5 44.0 83.5 39.0C83.5 33.5 79.5 31.0 75.0 31.0C70.5 31.0 66.5 33.5 66.5 39.0C66.5 44.0 71.5 47.0 69.0 50.0L50 50L50.0 31.0C47.0 28.5 44.0 33.5 39.0 33.5C33.5 33.5 31.0 29.5 31.0 25.0C31.0 20.5 33.5 16.5 39.0 16.5C44.0 16.5 47.0 21.5 50.0 19.0L50 0Z"></path>
</clipPath>
<clipPath id="card-jigsaw-c2">
<path d="M100 0L150 0L150 50L131.0 50.0C128.5 53.0 133.5 56.0 133.5 61.0C133.5 66.5 129.5 69.0 125.0 69.0C120.5 69.0 116.5 66.5 116.5 61.0C116.5 56.0 121.5 53.0 119.0 50.0L100 50L100.0 31.0C103.0 28.5 106.0 33.5 111.0 33.5C116.5 33.5 119.0 29.5 119.0 25.0C119.0 20.5 116.5 16.5 111.0 16.5C106.0 16.5 103.0 21.5 100.0 19.0L100 0Z"></path>
</clipPath>
<clipPath id="card-jigsaw-c3">
<path d="M0 50L19.0 50.0C21.5 53.0 16.5 56.0 16.5 61.0C16.5 66.5 20.5 69.0 25.0 69.0C29.5 69.0 33.5 66.5 33.5 61.0C33.5 56.0 28.5 53.0 31.0 50.0L50 50L50.0 69.0C53.0 71.5 56.0 66.5 61.0 66.5C66.5 66.5 69.0 70.5 69.0 75.0C69.0 79.5 66.5 83.5 61.0 83.5C56.0 83.5 53.0 78.5 50.0 81.0L50 100L31.0 100.0C28.5 97.0 33.5 94.0 33.5 89.0C33.5 83.5 29.5 81.0 25.0 81.0C20.5 81.0 16.5 83.5 16.5 89.0C16.5 94.0 21.5 97.0 19.0 100.0L0 100L0 50Z"></path>
</clipPath>
<clipPath id="card-jigsaw-c4">
<path d="M50 50L69.0 50.0C71.5 47.0 66.5 44.0 66.5 39.0C66.5 33.5 70.5 31.0 75.0 31.0C79.5 31.0 83.5 33.5 83.5 39.0C83.5 44.0 78.5 47.0 81.0 50.0L100 50L100.0 69.0C97.0 71.5 94.0 66.5 89.0 66.5C83.5 66.5 81.0 70.5 81.0 75.0C81.0 79.5 83.5 83.5 89.0 83.5C94.0 83.5 97.0 78.5 100.0 81.0L100 100L81.0 100.0C78.5 103.0 83.5 106.0 83.5 111.0C83.5 116.5 79.5 119.0 75.0 119.0C70.5 119.0 66.5 116.5 66.5 111.0C66.5 106.0 71.5 103.0 69.0 100.0L50 100L50.0 81.0C53.0 78.5 56.0 83.5 61.0 83.5C66.5 83.5 69.0 79.5 69.0 75.0C69.0 70.5 66.5 66.5 61.0 66.5C56.0 66.5 53.0 71.5 50.0 69.0L50 50Z"></path>
</clipPath>
<clipPath id="card-jigsaw-c5">
<path d="M100 50L119.0 50.0C121.5 53.0 116.5 56.0 116.5 61.0C116.5 66.5 120.5 69.0 125.0 69.0C129.5 69.0 133.5 66.5 133.5 61.0C133.5 56.0 128.5 53.0 131.0 50.0L150 50L150 100L131.0 100.0C128.5 97.0 133.5 94.0 133.5 89.0C133.5 83.5 129.5 81.0 125.0 81.0C120.5 81.0 116.5 83.5 116.5 89.0C116.5 94.0 121.5 97.0 119.0 100.0L100 100L100.0 81.0C97.0 78.5 94.0 83.5 89.0 83.5C83.5 83.5 81.0 79.5 81.0 75.0C81.0 70.5 83.5 66.5 89.0 66.5C94.0 66.5 97.0 71.5 100.0 69.0L100 50Z"></path>
</clipPath>
<clipPath id="card-jigsaw-c6">
<path d="M0 100L19.0 100.0C21.5 97.0 16.5 94.0 16.5 89.0C16.5 83.5 20.5 81.0 25.0 81.0C29.5 81.0 33.5 83.5 33.5 89.0C33.5 94.0 28.5 97.0 31.0 100.0L50 100L50.0 119.0C47.0 121.5 44.0 116.5 39.0 116.5C33.5 116.5 31.0 120.5 31.0 125.0C31.0 129.5 33.5 133.5 39.0 133.5C44.0 133.5 47.0 128.5 50.0 131.0L50 150L0 150L0 100Z"></path>
</clipPath>
<clipPath id="card-jigsaw-c7">
<path d="M50 100L69.0 100.0C71.5 103.0 66.5 106.0 66.5 111.0C66.5 116.5 70.5 119.0 75.0 119.0C79.5 119.0 83.5 116.5 83.5 111.0C83.5 106.0 78.5 103.0 81.0 100.0L100 100L100.0 119.0C103.0 121.5 106.0 116.5 111.0 116.5C116.5 116.5 119.0 120.5 119.0 125.0C119.0 129.5 116.5 133.5 111.0 133.5C106.0 133.5 103.0 128.5 100.0 131.0L100 150L50 150L50.0 131.0C47.0 128.5 44.0 133.5 39.0 133.5C33.5 133.5 31.0 129.5 31.0 125.0C31.0 120.5 33.5 116.5 39.0 116.5C44.0 116.5 47.0 121.5 50.0 119.0L50 100Z"></path>
</clipPath>
<clipPath id="card-jigsaw-c8">
<path d="M100 100L119.0 100.0C121.5 97.0 116.5 94.0 116.5 89.0C116.5 83.5 120.5 81.0 125.0 81.0C129.5 81.0 133.5 83.5 133.5 89.0C133.5 94.0 128.5 97.0 131.0 100.0L150 100L150 150L100 150L100.0 131.0C103.0 128.5 106.0 133.5 111.0 133.5C116.5 133.5 119.0 129.5 119.0 125.0C119.0 120.5 116.5 116.5 111.0 116.5C106.0 116.5 103.0 121.5 100.0 119.0L100 100Z"></path>
</clipPath>
</defs>
</svg>
<p class="card-jigsaw__head">
<b>Your profile</b>
<span>6 of 9</span>
</p>
<div class="card-jigsaw__board">
<svg class="card-jigsaw__well" width="150" height="150" aria-hidden="true">
<path class="card-jigsaw__slot" d="M100 0L150 0L150 50L131.0 50.0C128.5 53.0 133.5 56.0 133.5 61.0C133.5 66.5 129.5 69.0 125.0 69.0C120.5 69.0 116.5 66.5 116.5 61.0C116.5 56.0 121.5 53.0 119.0 50.0L100 50L100.0 31.0C103.0 28.5 106.0 33.5 111.0 33.5C116.5 33.5 119.0 29.5 119.0 25.0C119.0 20.5 116.5 16.5 111.0 16.5C106.0 16.5 103.0 21.5 100.0 19.0L100 0Z"></path>
<path class="card-jigsaw__slot" d="M50 50L69.0 50.0C71.5 47.0 66.5 44.0 66.5 39.0C66.5 33.5 70.5 31.0 75.0 31.0C79.5 31.0 83.5 33.5 83.5 39.0C83.5 44.0 78.5 47.0 81.0 50.0L100 50L100.0 69.0C97.0 71.5 94.0 66.5 89.0 66.5C83.5 66.5 81.0 70.5 81.0 75.0C81.0 79.5 83.5 83.5 89.0 83.5C94.0 83.5 97.0 78.5 100.0 81.0L100 100L81.0 100.0C78.5 103.0 83.5 106.0 83.5 111.0C83.5 116.5 79.5 119.0 75.0 119.0C70.5 119.0 66.5 116.5 66.5 111.0C66.5 106.0 71.5 103.0 69.0 100.0L50 100L50.0 81.0C53.0 78.5 56.0 83.5 61.0 83.5C66.5 83.5 69.0 79.5 69.0 75.0C69.0 70.5 66.5 66.5 61.0 66.5C56.0 66.5 53.0 71.5 50.0 69.0L50 50Z"></path>
<path class="card-jigsaw__slot" d="M0 100L19.0 100.0C21.5 97.0 16.5 94.0 16.5 89.0C16.5 83.5 20.5 81.0 25.0 81.0C29.5 81.0 33.5 83.5 33.5 89.0C33.5 94.0 28.5 97.0 31.0 100.0L50 100L50.0 119.0C47.0 121.5 44.0 116.5 39.0 116.5C33.5 116.5 31.0 120.5 31.0 125.0C31.0 129.5 33.5 133.5 39.0 133.5C44.0 133.5 47.0 128.5 50.0 131.0L50 150L0 150L0 100Z"></path>
</svg>
<span class="card-jigsaw__piece" style="--px:-14px;--py:-14px">
<svg viewBox="-14 -14 78 78" aria-hidden="true">
<use href="#card-jigsaw-pic" clip-path="url(#card-jigsaw-c0)"></use>
<path class="card-jigsaw__edge" d="M0 0L50 0L50.0 19.0C47.0 21.5 44.0 16.5 39.0 16.5C33.5 16.5 31.0 20.5 31.0 25.0C31.0 29.5 33.5 33.5 39.0 33.5C44.0 33.5 47.0 28.5 50.0 31.0L50 50L31.0 50.0C28.5 53.0 33.5 56.0 33.5 61.0C33.5 66.5 29.5 69.0 25.0 69.0C20.5 69.0 16.5 66.5 16.5 61.0C16.5 56.0 21.5 53.0 19.0 50.0L0 50L0 0Z"></path>
</svg>
</span>
<span class="card-jigsaw__piece" style="--px:36px;--py:-14px">
<svg viewBox="36 -14 78 78" aria-hidden="true">
<use href="#card-jigsaw-pic" clip-path="url(#card-jigsaw-c1)"></use>
<path class="card-jigsaw__edge" d="M50 0L100 0L100.0 19.0C103.0 21.5 106.0 16.5 111.0 16.5C116.5 16.5 119.0 20.5 119.0 25.0C119.0 29.5 116.5 33.5 111.0 33.5C106.0 33.5 103.0 28.5 100.0 31.0L100 50L81.0 50.0C78.5 47.0 83.5 44.0 83.5 39.0C83.5 33.5 79.5 31.0 75.0 31.0C70.5 31.0 66.5 33.5 66.5 39.0C66.5 44.0 71.5 47.0 69.0 50.0L50 50L50.0 31.0C47.0 28.5 44.0 33.5 39.0 33.5C33.5 33.5 31.0 29.5 31.0 25.0C31.0 20.5 33.5 16.5 39.0 16.5C44.0 16.5 47.0 21.5 50.0 19.0L50 0Z"></path>
</svg>
</span>
<button type="button" class="card-jigsaw__piece" aria-label="Add a photo" data-x="78" data-y="0" data-r="12" data-held="false" style="--px:86px;--py:-14px;--tx:78px;--ty:0px;--tr:12deg">
<svg viewBox="86 -14 78 78" aria-hidden="true">
<use href="#card-jigsaw-pic" clip-path="url(#card-jigsaw-c2)"></use>
<path class="card-jigsaw__edge" d="M100 0L150 0L150 50L131.0 50.0C128.5 53.0 133.5 56.0 133.5 61.0C133.5 66.5 129.5 69.0 125.0 69.0C120.5 69.0 116.5 66.5 116.5 61.0C116.5 56.0 121.5 53.0 119.0 50.0L100 50L100.0 31.0C103.0 28.5 106.0 33.5 111.0 33.5C116.5 33.5 119.0 29.5 119.0 25.0C119.0 20.5 116.5 16.5 111.0 16.5C106.0 16.5 103.0 21.5 100.0 19.0L100 0Z"></path>
</svg>
</button>
<span class="card-jigsaw__piece" style="--px:-14px;--py:36px">
<svg viewBox="-14 36 78 78" aria-hidden="true">
<use href="#card-jigsaw-pic" clip-path="url(#card-jigsaw-c3)"></use>
<path class="card-jigsaw__edge" d="M0 50L19.0 50.0C21.5 53.0 16.5 56.0 16.5 61.0C16.5 66.5 20.5 69.0 25.0 69.0C29.5 69.0 33.5 66.5 33.5 61.0C33.5 56.0 28.5 53.0 31.0 50.0L50 50L50.0 69.0C53.0 71.5 56.0 66.5 61.0 66.5C66.5 66.5 69.0 70.5 69.0 75.0C69.0 79.5 66.5 83.5 61.0 83.5C56.0 83.5 53.0 78.5 50.0 81.0L50 100L31.0 100.0C28.5 97.0 33.5 94.0 33.5 89.0C33.5 83.5 29.5 81.0 25.0 81.0C20.5 81.0 16.5 83.5 16.5 89.0C16.5 94.0 21.5 97.0 19.0 100.0L0 100L0 50Z"></path>
</svg>
</span>
<button type="button" class="card-jigsaw__piece" aria-label="Verify your email" data-x="130" data-y="8" data-r="-10" data-held="false" style="--px:36px;--py:36px;--tx:130px;--ty:8px;--tr:-10deg">
<svg viewBox="36 36 78 78" aria-hidden="true">
<use href="#card-jigsaw-pic" clip-path="url(#card-jigsaw-c4)"></use>
<path class="card-jigsaw__edge" d="M50 50L69.0 50.0C71.5 47.0 66.5 44.0 66.5 39.0C66.5 33.5 70.5 31.0 75.0 31.0C79.5 31.0 83.5 33.5 83.5 39.0C83.5 44.0 78.5 47.0 81.0 50.0L100 50L100.0 69.0C97.0 71.5 94.0 66.5 89.0 66.5C83.5 66.5 81.0 70.5 81.0 75.0C81.0 79.5 83.5 83.5 89.0 83.5C94.0 83.5 97.0 78.5 100.0 81.0L100 100L81.0 100.0C78.5 103.0 83.5 106.0 83.5 111.0C83.5 116.5 79.5 119.0 75.0 119.0C70.5 119.0 66.5 116.5 66.5 111.0C66.5 106.0 71.5 103.0 69.0 100.0L50 100L50.0 81.0C53.0 78.5 56.0 83.5 61.0 83.5C66.5 83.5 69.0 79.5 69.0 75.0C69.0 70.5 66.5 66.5 61.0 66.5C56.0 66.5 53.0 71.5 50.0 69.0L50 50Z"></path>
</svg>
</button>
<span class="card-jigsaw__piece" style="--px:86px;--py:36px">
<svg viewBox="86 36 78 78" aria-hidden="true">
<use href="#card-jigsaw-pic" clip-path="url(#card-jigsaw-c5)"></use>
<path class="card-jigsaw__edge" d="M100 50L119.0 50.0C121.5 53.0 116.5 56.0 116.5 61.0C116.5 66.5 120.5 69.0 125.0 69.0C129.5 69.0 133.5 66.5 133.5 61.0C133.5 56.0 128.5 53.0 131.0 50.0L150 50L150 100L131.0 100.0C128.5 97.0 133.5 94.0 133.5 89.0C133.5 83.5 129.5 81.0 125.0 81.0C120.5 81.0 116.5 83.5 116.5 89.0C116.5 94.0 121.5 97.0 119.0 100.0L100 100L100.0 81.0C97.0 78.5 94.0 83.5 89.0 83.5C83.5 83.5 81.0 79.5 81.0 75.0C81.0 70.5 83.5 66.5 89.0 66.5C94.0 66.5 97.0 71.5 100.0 69.0L100 50Z"></path>
</svg>
</span>
<button type="button" class="card-jigsaw__piece" aria-label="Write a short bio" data-x="178" data-y="14" data-r="8" data-held="false" style="--px:-14px;--py:86px;--tx:178px;--ty:14px;--tr:8deg">
<svg viewBox="-14 86 78 78" aria-hidden="true">
<use href="#card-jigsaw-pic" clip-path="url(#card-jigsaw-c6)"></use>
<path class="card-jigsaw__edge" d="M0 100L19.0 100.0C21.5 97.0 16.5 94.0 16.5 89.0C16.5 83.5 20.5 81.0 25.0 81.0C29.5 81.0 33.5 83.5 33.5 89.0C33.5 94.0 28.5 97.0 31.0 100.0L50 100L50.0 119.0C47.0 121.5 44.0 116.5 39.0 116.5C33.5 116.5 31.0 120.5 31.0 125.0C31.0 129.5 33.5 133.5 39.0 133.5C44.0 133.5 47.0 128.5 50.0 131.0L50 150L0 150L0 100Z"></path>
</svg>
</button>
<span class="card-jigsaw__piece" style="--px:36px;--py:86px">
<svg viewBox="36 86 78 78" aria-hidden="true">
<use href="#card-jigsaw-pic" clip-path="url(#card-jigsaw-c7)"></use>
<path class="card-jigsaw__edge" d="M50 100L69.0 100.0C71.5 103.0 66.5 106.0 66.5 111.0C66.5 116.5 70.5 119.0 75.0 119.0C79.5 119.0 83.5 116.5 83.5 111.0C83.5 106.0 78.5 103.0 81.0 100.0L100 100L100.0 119.0C103.0 121.5 106.0 116.5 111.0 116.5C116.5 116.5 119.0 120.5 119.0 125.0C119.0 129.5 116.5 133.5 111.0 133.5C106.0 133.5 103.0 128.5 100.0 131.0L100 150L50 150L50.0 131.0C47.0 128.5 44.0 133.5 39.0 133.5C33.5 133.5 31.0 129.5 31.0 125.0C31.0 120.5 33.5 116.5 39.0 116.5C44.0 116.5 47.0 121.5 50.0 119.0L50 100Z"></path>
</svg>
</span>
<span class="card-jigsaw__piece" style="--px:86px;--py:86px">
<svg viewBox="86 86 78 78" aria-hidden="true">
<use href="#card-jigsaw-pic" clip-path="url(#card-jigsaw-c8)"></use>
<path class="card-jigsaw__edge" d="M100 100L119.0 100.0C121.5 97.0 116.5 94.0 116.5 89.0C116.5 83.5 120.5 81.0 125.0 81.0C129.5 81.0 133.5 83.5 133.5 89.0C133.5 94.0 128.5 97.0 131.0 100.0L150 100L150 150L100 150L100.0 131.0C103.0 128.5 106.0 133.5 111.0 133.5C116.5 133.5 119.0 129.5 119.0 125.0C119.0 120.5 116.5 116.5 111.0 116.5C106.0 116.5 103.0 121.5 100.0 119.0L100 100Z"></path>
</svg>
</span>
<span class="card-jigsaw__sheen" aria-hidden="true"></span>
</div>
<p class="card-jigsaw__next" role="status">Next: Add a photo</p>
</section>.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: 150px; height: 150px; }
.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: 78px; height: 78px; 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; }
}function initCardJigsaw(root) {
// Each loose piece carries its step and where it lies in data attributes.
var pieces = [].map.call(root.querySelectorAll('button.card-jigsaw__piece'), function (el) {
return { el: el, task: el.getAttribute('aria-label'), x: +el.getAttribute('data-x'), y: +el.getAttribute('data-y'), r: +el.getAttribute('data-r'), placed: false };
});
var count = root.querySelector('.card-jigsaw__head span');
var next = root.querySelector('.card-jigsaw__next');
var focus = null;
var drag = null;
function pose(p, dx, dy, held) {
var s = p.el.style;
s.setProperty('--tx', p.placed ? '0px' : p.x + dx + 'px');
s.setProperty('--ty', p.placed ? '0px' : p.y + dy + 'px');
s.setProperty('--tr', p.placed || held ? '0deg' : p.r + 'deg');
p.el.setAttribute('data-held', String(!!held));
p.el.disabled = p.placed;
}
function refresh() {
var left = pieces.filter(function (p) {
return !p.placed;
});
count.textContent = 9 - left.length + ' of 9';
root.setAttribute('data-done', String(!left.length));
next.textContent = '';
if (left.length) {
next.textContent = 'Next: ' + (focus && !focus.placed ? focus : left[0]).task;
return;
}
var again = document.createElement('button');
again.type = 'button';
again.className = 'card-jigsaw__reset';
again.textContent = 'Start over';
again.addEventListener('click', function () {
pieces.forEach(function (p) {
p.placed = false;
pose(p, 0, 0);
});
refresh();
});
next.appendChild(document.createTextNode('Profile complete'));
next.appendChild(again);
}
function place(p) {
p.placed = true;
pose(p, 0, 0);
refresh();
}
var off = pieces.map(function (p) {
function onDown(e) {
p.el.setPointerCapture(e.pointerId);
drag = { sx: e.clientX, sy: e.clientY, moved: false, x: 0, y: 0 };
}
function onMove(e) {
if (!drag) return;
var x = e.clientX - drag.sx;
var y = e.clientY - drag.sy;
if (!drag.moved && Math.hypot(x, y) < 4) return;
drag.moved = true;
drag.x = x;
drag.y = y;
pose(p, x, y, true);
}
function onUp() {
var d = drag;
drag = null;
if (!d) return;
// A click sends it home; a drag has to end near its slot, or it falls back.
if (!d.moved || Math.hypot(p.x + d.x, p.y + d.y) < 34) place(p);
else pose(p, 0, 0);
}
function onCancel() {
drag = null;
pose(p, 0, 0);
}
function onEnter() {
focus = p;
refresh();
}
// A keyboard press has no pointer to follow, so it just sends the piece home.
function onClick(e) {
if (e.detail === 0) place(p);
}
var on = { pointerdown: onDown, pointermove: onMove, pointerup: onUp, pointercancel: onCancel, pointerenter: onEnter, focus: onEnter, click: onClick };
Object.keys(on).forEach(function (k) {
p.el.addEventListener(k, on[k]);
});
return function () {
Object.keys(on).forEach(function (k) {
p.el.removeEventListener(k, on[k]);
});
};
});
return function () {
off.forEach(function (fn) {
fn();
});
};
}
document.querySelectorAll('.card-jigsaw').forEach(initCardJigsaw);Goal: Create a "Jigsaw" card.
Visual behavior: 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.
Technique:
- Each piece is the same picture seen through its own clipPath, with interlocking knobs built from one curve and a pattern that keeps neighbouring edges matched.
- Loose pieces are buttons shifted away from their slots with transform variables. A click, or a drag that ends near the slot, sets those to zero so the same element flies home; a drag that misses falls back. Only the piece shape takes the pointer, so overlapping corners do not steal it.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Jigsaw" card.
Visual behavior: 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.
Technique:
- Each piece is the same picture seen through its own clipPath, with interlocking knobs built from one curve and a pattern that keeps neighbouring edges matched.
- Loose pieces are buttons shifted away from their slots with transform variables. A click, or a drag that ends near the slot, sets those to zero so the same element flies home; a drag that misses falls back. Only the piece shape takes the pointer, so overlapping corners do not steal it.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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 allReading list · 6 books