Interactive
IronFilings
A bar magnet on a sheet dusted with thousands of iron filings. Each filing turns along the true field of the two poles and stays where it lay when the magnet moves on, so the sheet keeps a trace. Click turns the magnet, arrows move it.
Download
- React
- Tailwind CSS v4
- motion
components/ui/iron-filings.tsx
"use client";
import { useEffect, useRef } from "react";
import { useReducedMotion } from "motion/react";
type IronFilingsProps = {
/** Sheet colour. */
paper?: string;
/** Filing colour. */
ink?: string;
/** Pixels between filings. Smaller is denser. */
spacing?: number;
/** Read out by screen readers. */
label?: string;
className?: string;
};
const MAX_FILINGS = 9000;
const REACH = 0.05; // Field below this cannot turn a filing, so it stays where it lay.
const fold = (a: number) => {
let d = a % Math.PI;
if (d > Math.PI / 2) d -= Math.PI;
if (d < -Math.PI / 2) d += Math.PI;
return d;
};
type Engine = { aim: (x: number, y: number) => void; nudge: (dx: number, dy: number) => void; turn: () => void };
/* IronFilings: a bar magnet under a sheet dusted with thousands of iron filings. Each filing turns along the true field of the two poles and leans toward the nearest one, and stays where it lay when the magnet moves on. */
export function IronFilings({ paper = "#efe6d3", ink = "#2b2622", spacing = 7, label = "Magnet on a sheet of iron filings. Arrow keys move it, Enter turns it.", className = "" }: IronFilingsProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const magnet = useRef<HTMLDivElement>(null);
const engine = useRef<Engine | null>(null);
const press = useRef({ x: 0, y: 0, down: false });
useEffect(() => {
const host = box.current!;
const view = canvas.current!;
const bar = magnet.current!;
const ctx = view.getContext("2d")!;
let w = 0;
let h = 0;
let span = 80; // Magnet length.
let count = 0;
let x = new Float32Array(0);
let y = new Float32Array(0);
let a = new Float32Array(0);
let ox = new Float32Array(0);
let oy = new Float32Array(0);
let len = new Float32Array(0);
let mx = 0;
let my = 0;
let tx = 0;
let ty = 0;
let rot = -0.35;
let turnTo = -0.35;
let raf = 0;
let ready = false;
const placeMagnet = () => {
bar.style.fontSize = `${span}px`;
bar.style.transform = `translate(${mx - w / 2}px, ${my - h / 2}px) translate(-50%, -50%) rotate(${rot}rad)`;
};
/* Moves every filing one frame on. Returns how much changed. */
const settle = (rate: number) => {
const ux = Math.cos(rot) * span * 0.36;
const uy = Math.sin(rot) * span * 0.36;
const nx = mx + ux;
const ny = my + uy;
const sx = mx - ux;
const sy = my - uy;
const unit = span * span;
let change = 0;
for (let i = 0; i < count; i++) {
const ax = x[i] - nx;
const ay = y[i] - ny;
const bx = x[i] - sx;
const by = y[i] - sy;
const ar = Math.max(1, ax * ax + ay * ay);
const br = Math.max(1, bx * bx + by * by);
const ad = ar * Math.sqrt(ar);
const bd = br * Math.sqrt(br);
const fx = ax / ad - bx / bd;
const fy = ay / ad - by / bd;
const power = Math.hypot(fx, fy) * unit;
if (power > REACH) {
const k = Math.min(1, rate * Math.min(1, power * 1.6));
const d = fold(Math.atan2(fy, fx) - a[i]) * k;
a[i] += d;
change += Math.abs(d);
}
const near = ar < br;
const dx = near ? -ax : -bx;
const dy = near ? -ay : -by;
const dist = Math.sqrt(near ? ar : br);
const lean = Math.min(span * 0.09, power * span * 0.05) / dist;
const gx = (dx * lean - ox[i]) * Math.min(1, rate * 1.2);
const gy = (dy * lean - oy[i]) * Math.min(1, rate * 1.2);
ox[i] += gx;
oy[i] += gy;
change += Math.abs(gx) + Math.abs(gy);
}
return change;
};
const paint = () => {
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = ink;
ctx.lineCap = "round";
for (let shade = 0; shade < 3; shade++) {
ctx.globalAlpha = 0.5 + shade * 0.22;
ctx.lineWidth = 0.8 + shade * 0.18;
ctx.beginPath();
for (let i = shade; i < count; i += 3) {
const c = Math.cos(a[i]) * len[i];
const s = Math.sin(a[i]) * len[i];
const px = x[i] + ox[i];
const py = y[i] + oy[i];
ctx.moveTo(px - c, py - s);
ctx.lineTo(px + c, py + s);
}
ctx.stroke();
}
};
const tick = () => {
const follow = reduce ? 1 : 0.2;
mx += (tx - mx) * follow;
my += (ty - my) * follow;
rot += (turnTo - rot) * (reduce ? 1 : 0.16);
placeMagnet();
const change = settle(reduce ? 1 : 0.16);
paint();
const still = Math.abs(tx - mx) + Math.abs(ty - my) + Math.abs(turnTo - rot) * 30 < 0.2;
raf = !still || change > count * 0.0015 ? requestAnimationFrame(tick) : 0;
};
const wake = () => {
if (!raf && ready) raf = requestAnimationFrame(tick);
};
const scatter = () => {
const rect = host.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const oldW = w;
const oldH = h;
w = rect.width;
h = rect.height;
view.width = Math.round(w * devicePixelRatio);
view.height = Math.round(h * devicePixelRatio);
span = Math.max(64, Math.min(170, Math.min(w, h) * 0.44));
if (oldW) {
tx *= w / oldW;
ty *= h / oldH;
mx *= w / oldW;
my *= h / oldH;
} else {
tx = mx = w / 2;
ty = my = h / 2;
}
const gap = Math.max(spacing, Math.sqrt((w * h) / MAX_FILINGS));
const cols = Math.ceil(w / gap);
const rows = Math.ceil(h / gap);
count = cols * rows;
x = new Float32Array(count);
y = new Float32Array(count);
a = new Float32Array(count);
ox = new Float32Array(count);
oy = new Float32Array(count);
len = new Float32Array(count);
for (let i = 0; i < count; i++) {
x[i] = ((i % cols) + Math.random()) * gap;
y[i] = (Math.floor(i / cols) + Math.random()) * gap;
a[i] = Math.random() * Math.PI;
len[i] = gap * (0.3 + Math.random() * 0.34);
}
// Shuffle so the three shades are spread evenly over the sheet.
for (let i = count - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[x[i], x[j]] = [x[j], x[i]];
[y[i], y[j]] = [y[j], y[i]];
}
for (let pass = 0; pass < 2; pass++) settle(1);
placeMagnet();
paint();
ready = true;
};
engine.current = {
aim(px, py) {
tx = Math.max(0, Math.min(w, px));
ty = Math.max(0, Math.min(h, py));
wake();
},
nudge(dx, dy) {
this.aim(tx + dx * span * 0.4, ty + dy * span * 0.4);
},
turn() {
turnTo += Math.PI / 4;
wake();
},
};
const watcher = new ResizeObserver(() => {
cancelAnimationFrame(raf);
raf = 0;
scatter();
});
watcher.observe(host);
return () => {
watcher.disconnect();
cancelAnimationFrame(raf);
engine.current = null;
};
}, [ink, spacing, reduce]);
const local = (e: React.PointerEvent) => {
const rect = box.current!.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
return (
<div
ref={box}
role="application"
tabIndex={0}
aria-label={label}
className={`relative cursor-grab touch-none select-none overflow-hidden ${className}`}
style={{ backgroundColor: paper }}
onPointerMove={(e) => {
const { x, y } = local(e);
engine.current?.aim(x, y);
}}
onMouseDown={(e) => e.preventDefault()}
onPointerDown={(e) => {
const { x, y } = local(e);
press.current = { x, y, down: true };
engine.current?.aim(x, y);
}}
onPointerUp={(e) => {
const { x, y } = local(e);
if (press.current.down && Math.hypot(x - press.current.x, y - press.current.y) < 6) engine.current?.turn();
press.current.down = false;
}}
onKeyDown={(e) => {
const move = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (move) {
e.preventDefault();
engine.current?.nudge(move[0], move[1]);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
engine.current?.turn();
}
}}
>
<canvas ref={canvas} aria-hidden="true" className="absolute inset-0 h-full w-full" />
<div aria-hidden="true" className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_55%,rgba(70,50,20,0.16))]" />
{/* All sizes in em: the font size is the magnet's length. */}
<div
ref={magnet}
aria-hidden="true"
className="absolute left-1/2 top-1/2 flex h-[0.27em] w-[1em] overflow-hidden rounded-[0.025em] shadow-[0.02em_0.05em_0.08em_rgba(40,25,5,0.55)]"
style={{ fontSize: 120, transform: "translate(-50%, -50%) rotate(-0.35rad)" }}
>
<span className="grid h-full w-1/2 place-items-center bg-[linear-gradient(#5f6f80,#3b4957_55%,#29343f)] text-[0.14em] font-bold text-white/85">S</span>
<span className="grid h-full w-1/2 place-items-center bg-[linear-gradient(#e15a4c,#c22f25_55%,#8f1c15)] text-[0.14em] font-bold text-white/90">N</span>
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/iron-filings.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/iron-filings-demo.tsx
import { IronFilings } from "@/components/ui/iron-filings"; export default function IronFilingsExample() { return ( <IronFilings className="h-full w-full" /> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| paper | string | "#efe6d3" | Sheet colour. |
| ink | string | "#2b2622" | Filing colour. |
| spacing | number | 7 | Pixels between filings. Smaller is denser, capped at 9000 filings. |
| label | string | Describes the keys | Read out by screen readers. |
| className | string | "h-80 w-full" | Set a height and width here; the sheet fills them. |