Success moment as magnetism — a field of drifting iron filings snaps to attention when the action confirms, migrating and aligning to draw the checkmark as revealed field lines, with a halo of off-path filings rotating to the local field direction.
npx shadcn add https://design.helpmarq.com /r/success-iron-filings.jsonregistry/loud/success-iron-filings/component.tsx"use client";
import { useEffect, useRef, useState } from "react";
// Success moment as magnetism: a field of scattered iron filings drifts
// aimlessly until the action confirms — then a field switches on and the
// filings migrate and ALIGN, drawing the checkmark as revealed field lines.
// Filings near the path snap onto it; the rest stay put but rotate to the
// local field direction, so the check emerges from the whole field, not a
// stamped glyph. All canvas ink is read from CSS tokens at mount and on
// theme change.
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
angle: number;
// assigned on confirm
tx: number;
ty: number;
tAngle: number;
delay: number;
onPath: boolean;
// idle drift
dx: number;
dy: number;
spin: number;
seed: number;
};
const COUNT = 520;
// checkmark polyline, normalized to the field box
const CHECK: [number, number][] = [
[0.3, 0.55],
[0.45, 0.72],
[0.72, 0.3],
];
function nearestOnCheck(
x: number,
y: number,
w: number,
h: number
): { px: number; py: number; tangent: number; dist: number } {
let best = { px: 0, py: 0, tangent: 0, dist: Infinity };
for (let s = 0; s < CHECK.length - 1; s++) {
const ax = CHECK[s][0] * w;
const ay = CHECK[s][1] * h;
const bx = CHECK[s + 1][0] * w;
const by = CHECK[s + 1][1] * h;
const abx = bx - ax;
const aby = by - ay;
const len2 = abx * abx + aby * aby;
const t = Math.max(
0,
Math.min(1, ((x - ax) * abx + (y - ay) * aby) / len2)
);
const px = ax + abx * t;
const py = ay + aby * t;
const d = Math.hypot(x - px, y - py);
if (d < best.dist) {
best = { px, py, tangent: Math.atan2(aby, abx), dist: d };
}
}
return best;
}
export function IronFilings({ className = "" }: { className?: string }) {
const [phase, setPhase] = useState<"idle" | "pending" | "done">("idle");
const fieldRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const phaseRef = useRef(phase);
phaseRef.current = phase;
const reduced = useRef(false);
const confirmAt = useRef(0);
useEffect(() => {
const field = fieldRef.current;
const canvas = canvasRef.current;
if (!field || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
reduced.current = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
let w = 0;
let h = 0;
let ink = { fg: "#888", muted: "#888" };
let raf = 0;
let last = 0;
let running = false;
const readInk = () => {
const cs = getComputedStyle(document.documentElement);
ink = {
fg: cs.getPropertyValue("--foreground").trim() || "#888",
muted: cs.getPropertyValue("--muted").trim() || "#888",
};
};
readInk();
// deterministic-ish scatter, reseeded per mount
const rand = (() => {
let s = 1234567;
return () => {
s = (s * 1103515245 + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
})();
const particles: Particle[] = Array.from({ length: COUNT }, () => ({
x: 0,
y: 0,
vx: 0,
vy: 0,
angle: rand() * Math.PI,
tx: 0,
ty: 0,
tAngle: 0,
delay: 0,
onPath: false,
dx: (rand() - 0.5) * 5,
dy: (rand() - 0.5) * 5,
spin: (rand() - 0.5) * 0.35,
seed: rand(),
}));
let seeded = false;
const resize = () => {
const rect = field.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) return;
w = rect.width;
h = rect.height;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (!seeded) {
for (const p of particles) {
p.x = rand() * w;
p.y = rand() * h;
}
seeded = true;
}
if (phaseRef.current === "done") assignTargets();
wake();
};
const assignTargets = () => {
// 68% land on the stroke itself, the rest align in place as halo
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
p.onPath = p.seed < 0.68;
if (p.onPath) {
const t = (i / particles.length + p.seed * 0.13) % 1;
// walk the polyline by arc length
const l1 = Math.hypot(
(CHECK[1][0] - CHECK[0][0]) * w,
(CHECK[1][1] - CHECK[0][1]) * h
);
const l2 = Math.hypot(
(CHECK[2][0] - CHECK[1][0]) * w,
(CHECK[2][1] - CHECK[1][1]) * h
);
const d = t * (l1 + l2);
const seg = d < l1 ? 0 : 1;
const st = seg === 0 ? d / l1 : (d - l1) / l2;
const ax = CHECK[seg][0] * w;
const ay = CHECK[seg][1] * h;
const bx = CHECK[seg + 1][0] * w;
const by = CHECK[seg + 1][1] * h;
const tangent = Math.atan2(by - ay, bx - ax);
// thickness: gaussian-ish normal offset builds a soft stroke
const off =
(p.seed - 0.5 + (((p.seed * 7919) % 1) - 0.5)) * 7;
p.tx = ax + (bx - ax) * st + Math.cos(tangent + Math.PI / 2) * off;
p.ty = ay + (by - ay) * st + Math.sin(tangent + Math.PI / 2) * off;
p.tAngle = tangent + (p.seed - 0.5) * 0.3;
p.delay = t * 0.45 + p.seed * 0.15;
} else {
const near = nearestOnCheck(p.x, p.y, w, h);
// halo filings creep slightly toward the field, mostly rotate
p.tx = p.x + (near.px - p.x) * 0.12;
p.ty = p.y + (near.py - p.y) * 0.12;
p.tAngle = near.tangent;
p.delay = p.seed * 0.5;
}
}
confirmAt.current = performance.now();
};
const K = 130;
const ZETA = 0.82;
const draw = (dt: number, now: number) => {
ctx.clearRect(0, 0, w, h);
const done = phaseRef.current === "done";
const elapsed = (now - confirmAt.current) / 1000;
let settled = true;
for (const p of particles) {
if (done) {
if (elapsed > p.delay) {
const c = 2 * ZETA * Math.sqrt(K);
p.vx += (K * (p.tx - p.x) - c * p.vx) * dt;
p.vy += (K * (p.ty - p.y) - c * p.vy) * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
let dA = p.tAngle - p.angle;
while (dA > Math.PI / 2) dA -= Math.PI;
while (dA < -Math.PI / 2) dA += Math.PI;
p.angle += dA * Math.min(1, dt * 7);
if (
Math.abs(p.tx - p.x) > 0.4 ||
Math.abs(p.vx) > 3 ||
Math.abs(dA) > 0.02
)
settled = false;
} else {
// pre-delay: burst velocity coasts under drag, so a replay's
// outward fling is visible before the field pulls back
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vx -= p.vx * 3 * dt;
p.vy -= p.vy * 3 * dt;
settled = false;
}
} else {
// aimless drift, wrapped
p.x += p.dx * dt;
p.y += p.dy * dt;
p.angle += p.spin * dt;
if (p.x < -4) p.x = w + 4;
if (p.x > w + 4) p.x = -4;
if (p.y < -4) p.y = h + 4;
if (p.y > h + 4) p.y = -4;
settled = false;
}
const len = p.onPath && done ? 3.4 : 2.8;
const alpha = done ? (p.onPath ? 0.95 : 0.28) : 0.4;
ctx.strokeStyle = done && p.onPath ? ink.fg : ink.muted;
ctx.globalAlpha = alpha;
ctx.lineWidth = 1.1;
ctx.beginPath();
ctx.moveTo(
p.x - Math.cos(p.angle) * len,
p.y - Math.sin(p.angle) * len
);
ctx.lineTo(
p.x + Math.cos(p.angle) * len,
p.y + Math.sin(p.angle) * len
);
ctx.stroke();
}
ctx.globalAlpha = 1;
return settled;
};
const loop = (t: number) => {
const dt = Math.min((t - (last || t)) / 1000, 1 / 30);
last = t;
const settled = draw(dt, t);
if (settled || (reduced.current && phaseRef.current !== "done")) {
running = false;
return;
}
raf = requestAnimationFrame(loop);
};
const wake = () => {
if (!running) {
running = true;
last = 0;
raf = requestAnimationFrame(loop);
}
};
// reduced motion: skip migration entirely — jump to the final frame
const snapToFinal = () => {
for (const p of particles) {
p.x = p.tx;
p.y = p.ty;
p.angle = p.tAngle;
p.vx = p.vy = 0;
p.delay = 0;
}
confirmAt.current = -Infinity;
draw(0, performance.now());
};
const api = {
confirm: () => {
assignTargets();
if (reduced.current) snapToFinal();
else wake();
},
replay: () => {
// burst outward, then re-align — the celebration replays
for (const p of particles) {
p.vx += (p.seed - 0.5) * 1600;
p.vy += (((p.seed * 7919) % 1) - 0.5) * 1600;
}
assignTargets();
if (reduced.current) snapToFinal();
else wake();
},
};
(
field as HTMLDivElement & { __filings?: typeof api }
).__filings = api;
const ro = new ResizeObserver(resize);
ro.observe(field);
resize();
const themeObserver = new MutationObserver(() => {
readInk();
wake();
if (reduced.current) draw(0, performance.now());
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const onVis = () => {
if (document.hidden) {
cancelAnimationFrame(raf);
running = false;
} else wake();
};
document.addEventListener("visibilitychange", onVis);
if (!reduced.current) wake();
else draw(0, performance.now());
return () => {
ro.disconnect();
themeObserver.disconnect();
document.removeEventListener("visibilitychange", onVis);
cancelAnimationFrame(raf);
delete (field as HTMLDivElement & { __filings?: typeof api })
.__filings;
};
}, []);
const act = (fn: "confirm" | "replay") => {
(
fieldRef.current as
| (HTMLDivElement & {
__filings?: { confirm: () => void; replay: () => void };
})
| null
)?.__filings?.[fn]();
};
const onClick = () => {
if (phase === "pending") return;
if (phase === "done") {
act("replay");
return;
}
setPhase("pending");
window.setTimeout(() => {
setPhase("done");
act("confirm");
}, 350);
};
return (
<div
className={`relative overflow-hidden rounded-md border border-border bg-background ${className}`}
>
{/* the field */}
<div ref={fieldRef} className="relative h-64 w-full">
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0"
/>
<div
aria-live="polite"
className="absolute inset-x-0 bottom-4 text-center"
>
{phase === "done" && (
<p
data-filings-done=""
className="font-mono text-xs uppercase tracking-[0.2em] text-foreground"
>
Payment confirmed
</p>
)}
{phase === "pending" && (
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted">
Processing
</p>
)}
</div>
</div>
<div className="flex items-center justify-between border-t border-border px-5 py-4">
<div
className={`transition-opacity duration-300 ${phase === "done" ? "opacity-40" : ""}`}
>
<p className="text-sm font-medium text-foreground">Order #1284</p>
<p className="font-mono text-xs text-muted">3 items · $148.00</p>
</div>
<button
type="button"
onClick={onClick}
disabled={phase === "pending"}
className="rounded-sm bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:opacity-60"
>
{phase === "done"
? "Replay"
: phase === "pending"
? "Processing…"
: "Confirm payment"}
</button>
</div>
</div>
);
}
Build a payment-confirmation card whose success state is rendered as iron filings revealing a magnetic field. A Canvas 2D layer over the card's upper field region holds ~520 short line-segment filings (1.1px stroke, ~6px long). IDLE: filings drift aimlessly on per-particle velocities (a few px/s) with slow random spin, wrapped at the edges, drawn in --muted at alpha 0.4 — an aimless field with no organizing force. ON CONFIRM (after a short 350 ms processing beat, button disabled meanwhile): the field switches on. 68% of filings are assigned arc-length-parameterized targets along a two-segment checkmark polyline (normalized points (0.30,0.55) (0.45,0.72) (0.72,0.30) of the field box) with a soft gaussian-ish normal offset up to ~7px so the stroke has filing-built thickness, target angle = local tangent plus jitter; the remaining 32% are HALO filings that creep only 12% toward their nearest point on the polyline and rotate to that point's tangent at low alpha 0.28 — the check emerges from the whole field aligning, not a stamped glyph. Each filing starts after a delay proportional to its arc-length parameter (plus jitter), so alignment sweeps along the stroke; position moves on an underdamped spring (k = 130 s^-2, zeta = 0.82), angle eases along the shortest half-turn (filings are unsigned — fold angle deltas into ±90°). Path filings draw in --foreground alpha 0.95, halo in --muted. All ink is read from getComputedStyle CSS custom properties at mount and re-read via a MutationObserver on the documentElement class attribute so both themes render. The rAF loop is refs-only, sleeps when every filing settles under epsilon, pauses on visibilitychange, and the canvas uses a dpr-clamped(2) backing store with a zero-size guard; a 'Payment confirmed' mono label appears in an aria-live=polite region under the check, the order summary dims, and the button becomes Replay — replaying kicks every filing with a burst velocity (coasting under drag until its stagger delay elapses) and reassigns targets, so the check explodes outward and the field pulls it back together. REDUCED MOTION: idle drift is a single static frame and confirm jumps straight to the fully-aligned final frame with no migration. Colors from theme tokens only; --accent appears solely on the interactive button.