Physics Letters
Huge letters that drop in, bounce, pile up and can be thrown.
Customize
<PhysicsLetters />Changes show live and stay in the address, so a link keeps them.
Code
"use client";
import { useEffect, useRef, useState } from "react";
import { Bodies, Body, Composite, Constraint, Engine, Events, Vector, type IEventCollision } from "matter-js";
// A word set in huge letters that drop in one by one, bounce and settle into a pile on the floor.
// Matter.js does the physics: every letter's body is the convex hull of its ink (traced once from
// the glyph's pixels), so an A lands as a triangle and nothing leans on empty air. The letters
// themselves are real SVG text, moved to their body's position and angle on every frame, so they
// stay crisp at any size. Grab one with the mouse or a finger and throw it.
export type PhysicsLettersProps = {
/** The word. Spaces leave a gap in the row. Up to 24 characters. */
text?: string;
/** How hard the letters fall. 1 is normal, 3 is heavy. */
gravity?: number;
/** How much a letter bounces when it lands, from 0 (dead) to 0.9 (rubber). */
bounciness?: number;
/** The time between one letter dropping and the next, in seconds. */
dropStagger?: number;
/** The weight of the font, 500 to 900. Heavy weights look best as physics blocks. */
fontWeight?: number;
/** The font family. "inherit" uses the font of the box around it. */
fontFamily?: string;
/** The colour of the letters. "currentColor" follows the text colour around it. */
letterColour?: string;
/** Paint every Nth letter in the accent colour. 0 paints none. */
accentEvery?: number;
/** The colour of the accent letters. */
accentColour?: string;
/** The text of the button that drops the letters again. An empty string hides the button. */
resetLabel?: string;
/** Extra classes for the reset button, e.g. to move it. */
resetClassName?: string;
/** Extra classes for the outer box. Give it a size (it fills it). */
className?: string;
};
// The physics runs at a fixed 60 steps a second, whatever the screen's refresh rate
const STEP = 1000 / 60;
// Letters are measured once at this size, then scaled
const MEASURE_SIZE = 100;
// No letter moves faster than this (pixels per step), so a hard throw can't pass through a wall
const MAX_SPEED = 55;
const WALL = 400;
// A glyph at MEASURE_SIZE: its ink box, the hull of its ink (from the ink box's top-left corner),
// and how far the hull's centre of mass sits from the ink box's centre. Matter puts a body's
// position at its centre of mass, so the element is drawn back from it by that offset.
type Glyph = { char: string; width: number; height: number; left: number; ascent: number; hull: Vector[] | null; offset: Vector };
type Letter = Glyph & { index: number; body: Body | null; dropAt: number; lastSquash: number };
export function PhysicsLetters({
text = "DIMAAC",
gravity = 1,
bounciness = 0.35,
dropStagger = 0.12,
fontWeight = 800,
fontFamily = "inherit",
letterColour = "currentColor",
accentEvery = 0,
accentColour = "#ddfc3e",
resetLabel = "Drop again",
resetClassName = "",
className = "",
}: PhysicsLettersProps) {
const boxRef = useRef<HTMLDivElement>(null);
const letterRefs = useRef<(HTMLDivElement | null)[]>([]);
// Set by the physics effect, so the button can drop the letters again without a rebuild
const resetRef = useRef<() => void>(() => {});
// Read on every step, so these change live without rebuilding anything
const live = useRef({ gravity, bounciness, dropStagger });
// Bumped when the reduced-motion setting changes, so the world rebuilds with the new mode
const [motionKey, setMotionKey] = useState(0);
live.current = { gravity, bounciness, dropStagger };
const word = text.slice(0, 24);
const chars = [...word];
const letters = chars.map((char, position) => ({ char, position })).filter(({ char }) => char.trim() !== "");
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => setMotionKey((key) => key + 1);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
useEffect(() => {
const box = boxRef.current;
if (!box || letters.length === 0) return;
const elements = letterRefs.current.slice(0, letters.length);
// Read fresh on every build; a change of the setting bumps motionKey and rebuilds
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let cancelled = false;
let frame = 0;
let cleanupWorld = () => {};
const start = async () => {
const family = getComputedStyle(box).fontFamily;
const font = `${fontWeight} ${MEASURE_SIZE}px ${family}`;
// Measure only once the font is in, or the bodies get the fallback font's shapes
try {
await document.fonts.load(font, word);
} catch {
// A font that fails to load still measures (as the fallback), so carry on
}
if (cancelled) return;
cleanupWorld = buildWorld(box, elements, measure(font, chars), reduced);
};
// Each letter's ink box and ink hull at MEASURE_SIZE. Spaces are kept (as a gap) so the row reads right.
const measure = (font: string, all: string[]): (Glyph | null)[] => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return all.map(() => null);
context.font = font;
return all.map((char) => {
if (char.trim() === "") return null;
const m = context.measureText(char);
const glyph = { char, width: m.actualBoundingBoxLeft + m.actualBoundingBoxRight, height: m.actualBoundingBoxAscent + m.actualBoundingBoxDescent, left: m.actualBoundingBoxLeft, ascent: m.actualBoundingBoxAscent };
const hull = traceHull(canvas, context, font, glyph);
const centre = hull ? centroid(hull) : { x: glyph.width / 2, y: glyph.height / 2 };
return { ...glyph, hull, offset: { x: centre.x - glyph.width / 2, y: centre.y - glyph.height / 2 } };
});
};
// Paint the glyph once and take the convex hull of its inked pixels. A hull needs no
// poly-decomp and is cheap to collide; holes and notches (the inside of a C) don't matter
// for a pile. Returns null (a rectangle is used) if the canvas can't be read.
const traceHull = (canvas: HTMLCanvasElement, context: CanvasRenderingContext2D, font: string, glyph: Omit<Glyph, "hull" | "offset">) => {
const pad = 2;
const w = Math.ceil(glyph.width) + pad * 2;
const h = Math.ceil(glyph.height) + pad * 2;
canvas.width = w;
canvas.height = h;
context.font = font;
context.fillStyle = "#000";
context.fillText(glyph.char, pad + glyph.left, pad + glyph.ascent);
let pixels: Uint8ClampedArray;
try {
pixels = context.getImageData(0, 0, w, h).data;
} catch {
return null;
}
// The first and last inked pixel of every row is all a convex hull needs
const points: Vector[] = [];
for (let y = 0; y < h; y++) {
let first = -1;
let last = -1;
for (let x = 0; x < w; x++) {
if (pixels[(y * w + x) * 4 + 3]! > 60) {
if (first < 0) first = x;
last = x;
}
}
if (first < 0) continue;
points.push({ x: first - pad, y: y - pad }, { x: first - pad, y: y + 1 - pad }, { x: last + 1 - pad, y: y - pad }, { x: last + 1 - pad, y: y + 1 - pad });
}
if (points.length < 3) return null;
return simplify(convexHull(points));
};
// Andrew's monotone chain: the smallest convex outline around the points, clockwise on screen
const convexHull = (points: Vector[]) => {
const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
const turn = (o: Vector, a: Vector, b: Vector) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
const half = (list: Vector[]) => {
const out: Vector[] = [];
list.forEach((point) => {
while (out.length >= 2 && turn(out[out.length - 2]!, out[out.length - 1]!, point) <= 0) out.pop();
out.push(point);
});
out.pop();
return out;
};
return [...half(sorted), ...half([...sorted].reverse())];
};
// The centre of mass of a polygon (the same point Matter puts a body's position on)
const centroid = (points: Vector[]) => {
let area = 0;
let x = 0;
let y = 0;
points.forEach((a, i) => {
const b = points[(i + 1) % points.length]!;
const cross = a.x * b.y - b.x * a.y;
area += cross;
x += (a.x + b.x) * cross;
y += (a.y + b.y) * cross;
});
return { x: x / (3 * area), y: y / (3 * area) };
};
// Round letters give hulls with dozens of tiny edges. Drop the corners that barely bend the
// outline (under a fifth of a pixel at MEASURE_SIZE) so collisions stay cheap and stable.
const simplify = (hull: Vector[]) => {
const points = [...hull];
let removed = true;
while (removed && points.length > 8) {
removed = false;
for (let i = 0; i < points.length && points.length > 8; i++) {
const a = points[(i + points.length - 1) % points.length]!;
const b = points[i]!;
const c = points[(i + 1) % points.length]!;
const area = Math.abs((b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y)) / 2;
const base = Math.hypot(c.x - a.x, c.y - a.y) || 1;
if ((area * 2) / base < 0.2) {
points.splice(i, 1);
removed = true;
i--;
}
}
}
return points;
};
const buildWorld = (box: HTMLDivElement, elements: (HTMLDivElement | null)[], glyphs: (Glyph | null)[], reduced: boolean) => {
const engine = Engine.create({ gravity: { x: 0, y: live.current.gravity }, positionIterations: 10, velocityIterations: 8 });
let walls: Body[] = [];
let width = 0;
let height = 0;
let scale = 1;
let clock = 0;
let visible = false;
let grab: { constraint: Constraint; letter: Letter; pointerId: number } | null = null;
const shapes = glyphs.filter((glyph): glyph is Glyph => glyph !== null);
const all: Letter[] = shapes.map((glyph, index) => ({ ...glyph, index, body: null, dropAt: 0, lastSquash: 0 }));
const gap = MEASURE_SIZE * 0.07;
const spaceWidth = MEASURE_SIZE * 0.32;
// Where each letter sits in a neat row, at MEASURE_SIZE: its centre from the row's left edge
const rowCentres: number[] = [];
let rowWidth = 0;
glyphs.forEach((glyph) => {
if (!glyph) {
rowWidth += spaceWidth;
return;
}
if (rowCentres.length > 0) rowWidth += gap;
rowCentres.push(rowWidth + glyph.width / 2);
rowWidth += glyph.width;
});
const tallest = Math.max(...shapes.map((glyph) => glyph.height));
// The word nearly fills the box. On a tall, narrow box (a phone) it's set bigger than the
// width, so the letters can't fit in one row: they land on each other in a pile (or, with
// reduced motion, sit in two neat lines).
const fit = () => {
const portrait = height > width * 1.2;
const fill = portrait ? 1.6 : 0.9;
const byWidth = (width * fill) / rowWidth;
const byHeight = (height * (portrait ? 0.3 : 0.5)) / tallest;
const byLetter = (width * 0.42) / Math.max(...shapes.map((glyph) => glyph.width));
return Math.max(0.05, Math.min(byWidth, byHeight, byLetter));
};
// Size each letter's element and its SVG text to the current scale
const sizeElements = () => {
all.forEach((letter) => {
const element = elements[letter.index];
const svg = element?.firstElementChild;
const label = svg?.firstElementChild;
if (!element || !svg || !label) return;
const w = letter.width * scale;
const h = letter.height * scale;
element.style.width = `${w}px`;
element.style.height = `${h}px`;
svg.setAttribute("viewBox", `0 0 ${letter.width} ${letter.height}`);
label.setAttribute("x", String(letter.left));
label.setAttribute("y", String(letter.ascent));
label.setAttribute("font-size", String(MEASURE_SIZE));
});
};
// The letter's ink-box offset from its body's position, at the current scale and angle
const offsetAt = (letter: Letter, angle: number) => Vector.rotate(Vector.mult(letter.offset, scale), angle);
// A body for the letter whose ink box is centred on (x, y), turned by angle
const makeBody = (letter: Letter, x: number, y: number, angle: number) => {
const w = letter.width * scale;
const h = letter.height * scale;
const options = { restitution: live.current.bounciness, friction: 0.45, frictionStatic: 0.9, frictionAir: 0.008, density: 0.002, slop: 0.02 };
const position = Vector.add({ x, y }, offsetAt(letter, angle));
const body = letter.hull
? Bodies.fromVertices(position.x, position.y, [letter.hull.map((point) => Vector.mult(point, scale))], options)
: Bodies.rectangle(position.x, position.y, w, h, { ...options, chamfer: { radius: Math.min(w, h) * 0.06 } });
// fromVertices centres the body on its centre of mass; set it exactly, then turn it
Body.setPosition(body, position);
Body.setAngle(body, angle);
return body;
};
// The centre of the letter's ink box, where its element is drawn
const inkCentre = (letter: Letter, body: Body, atScale = scale) =>
Vector.sub(body.position, Vector.rotate(Vector.mult(letter.offset, atScale), body.angle));
// Letters keep a little air from the side walls, so the pile doesn't hug one edge
const gutter = () => Math.max(8, width * 0.015);
const buildWalls = () => {
if (walls.length) Composite.remove(engine.world, walls);
const options = { isStatic: true, friction: 0.6, restitution: 0.2 };
const inset = gutter();
walls = [
Bodies.rectangle(width / 2, height + WALL / 2, width + WALL * 4, WALL, options),
Bodies.rectangle(inset - WALL / 2, height / 2 - height, WALL, height * 4, options),
Bodies.rectangle(width - inset + WALL / 2, height / 2 - height, WALL, height * 4, options),
// The lid sits far above the box, so letters can drop in from outside and a thrown one comes back
Bodies.rectangle(width / 2, -height * 2.5 - WALL / 2, width + WALL * 4, WALL, options),
];
Composite.add(engine.world, walls);
};
// The letters' neat row, wrapped into lines that fit, resting on the floor. Used for reduced
// motion, and as the x each letter drops from.
const neatRow = () => {
const lines: { letter: Letter; centre: number }[][] = [[]];
let lineStart = 0;
all.forEach((letter, i) => {
const centre = rowCentres[i]! * scale;
const right = centre + (letter.width * scale) / 2;
if (right - lineStart > width * 0.92 && lines[lines.length - 1]!.length > 0) {
lineStart = centre - (letter.width * scale) / 2;
lines.push([]);
}
lines[lines.length - 1]!.push({ letter, centre: centre - lineStart });
});
// A little air between lines, so round letters (which overshoot) don't touch the line above
const lineHeight = tallest * scale * 1.14;
return lines.flatMap((line, lineIndex) => {
const last = line[line.length - 1]!;
const lineWidth = last.centre + (last.letter.width * scale) / 2;
const offset = (width - lineWidth) / 2;
const floor = height - (lines.length - 1 - lineIndex) * lineHeight;
return line.map(({ letter, centre }) => ({ letter, x: offset + centre, y: floor - (letter.height * scale) / 2 }));
});
};
const removeLetters = () => {
if (grab) release();
all.forEach((letter) => {
if (letter.body) Composite.remove(engine.world, letter.body);
letter.body = null;
});
};
// Queue every letter to drop (or, with reduced motion, set them straight down in a row)
const drop = () => {
removeLetters();
clock = 0;
if (reduced) {
// Pinned in place, so the rows stay neat. A letter comes loose when it's grabbed.
neatRow().forEach(({ letter, x, y }) => {
letter.body = makeBody(letter, x, y, 0);
Body.setStatic(letter.body, true);
Composite.add(engine.world, letter.body);
});
render();
return;
}
all.forEach((letter, i) => {
letter.dropAt = 0.15 + i * live.current.dropStagger;
});
render();
};
const spawn = (letter: Letter) => {
// Spread the drop points over the width in word order, so the pile still reads left to right
const centre = rowCentres[letter.index]! / rowWidth;
const w = letter.width * scale;
const h = letter.height * scale;
const inset = gutter();
const x = Math.min(width - inset - w / 2, Math.max(inset + w / 2, inset + w / 2 + centre * (width - inset * 2 - w) + (Math.random() - 0.5) * w * 0.3));
const y = -h * 0.8 - Math.random() * h * 0.4;
letter.body = makeBody(letter, x, y, (Math.random() - 0.5) * 0.6);
Body.setVelocity(letter.body, { x: (Math.random() - 0.5) * 2, y: 4 });
Body.setAngularVelocity(letter.body, (Math.random() - 0.5) * 0.06);
Composite.add(engine.world, letter.body);
};
// Move every letter's element to its body. Letters still waiting to drop stay hidden.
const render = () => {
all.forEach((letter) => {
const element = elements[letter.index];
if (!element) return;
const body = letter.body;
if (!body) {
element.style.visibility = "hidden";
return;
}
const w = letter.width * scale;
const h = letter.height * scale;
const centre = inkCentre(letter, body);
element.style.visibility = "visible";
element.style.transform = `translate3d(${centre.x - w / 2}px, ${centre.y - h / 2}px, 0) rotate(${body.angle}rad)`;
});
};
// A quick squash on a hard landing makes the letters feel heavy and soft at the same time
const squash = (letter: Letter, speed: number) => {
const svg = elements[letter.index]?.firstElementChild;
if (reduced || !svg || clock - letter.lastSquash < 0.15) return;
letter.lastSquash = clock;
const amount = Math.min(0.14, (speed - 5) * 0.012);
svg.animate([{ transform: `scale(${1 + amount}, ${1 - amount})` }, { transform: "scale(1, 1)" }], { duration: 520, easing: "cubic-bezier(0.25, 1.8, 0.5, 1)" });
};
const onCollision = (event: IEventCollision<Engine>) => {
event.pairs.forEach((pair) => {
const speed = Vector.magnitude(Vector.sub(pair.bodyA.velocity, pair.bodyB.velocity));
if (speed < 6) return;
all.forEach((letter) => {
if (letter.body === pair.bodyA || letter.body === pair.bodyB) squash(letter, speed);
});
});
};
Events.on(engine, "collisionStart", onCollision);
const step = () => {
engine.gravity.y = live.current.gravity;
clock += STEP / 1000;
all.forEach((letter) => {
if (!letter.body && !reduced && clock >= letter.dropAt) spawn(letter);
const body = letter.body;
if (!body) return;
body.restitution = live.current.bounciness;
const speed = Vector.magnitude(body.velocity);
if (speed > MAX_SPEED) Body.setVelocity(body, Vector.mult(body.velocity, MAX_SPEED / speed));
// A letter that still escaped (squeezed past a wall) comes back from the top
if (body.position.x < -WALL || body.position.x > width + WALL || body.position.y > height + WALL) {
Body.setPosition(body, Vector.add({ x: width / 2, y: -letter.height * scale }, offsetAt(letter, body.angle)));
Body.setVelocity(body, { x: 0, y: 0 });
}
});
Engine.update(engine, STEP);
};
let last = performance.now();
let carry = 0;
const loop = (now: number) => {
frame = requestAnimationFrame(loop);
const elapsed = Math.min(now - last, 100);
last = now;
// Nothing moves while the box is off screen, and the drop waits until it's seen
if (!visible) return;
carry += elapsed;
let steps = 0;
while (carry >= STEP && steps < 4) {
step();
carry -= STEP;
steps++;
}
if (steps === 4) carry = 0;
if (steps > 0) render();
};
const layout = (first: boolean) => {
const nextWidth = box.clientWidth;
const nextHeight = box.clientHeight;
if (nextWidth === 0 || nextHeight === 0) return;
const oldWidth = width;
const oldHeight = height;
const oldScale = scale;
width = nextWidth;
height = nextHeight;
scale = fit();
buildWalls();
sizeElements();
if (first) {
drop();
return;
}
// Keep each fallen letter where it was, in proportion, at its new size
all.forEach((letter) => {
const body = letter.body;
if (!body) return;
const centre = inkCentre(letter, body, oldScale);
const x = (centre.x / oldWidth) * width;
const y = Math.min((centre.y / oldHeight) * height, height - (letter.height * scale) / 2);
const angle = body.angle;
if (grab?.letter === letter) release();
Composite.remove(engine.world, body);
letter.body = makeBody(letter, x, y, angle);
if (body.isStatic) Body.setStatic(letter.body, true);
Composite.add(engine.world, letter.body);
});
if (reduced && oldScale !== scale) drop();
render();
};
// Grabbing: a springy constraint pulls the letter toward the pointer, from the spot you
// grabbed it, so it swings and tumbles like a real object. Letting go keeps its speed.
const pointFrom = (event: PointerEvent) => {
const rect = box.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
};
const release = () => {
if (!grab) return;
const { constraint, letter } = grab;
Composite.remove(engine.world, constraint);
const element = elements[letter.index];
if (element) element.style.cursor = "";
if (reduced && letter.body) {
// Reduced motion: no throw, the letter just drops from where you let it go
Body.setVelocity(letter.body, { x: 0, y: 0 });
Body.setAngularVelocity(letter.body, 0);
}
grab = null;
};
const onPointerDown = (event: PointerEvent) => {
if (!event.isPrimary || event.button > 0) return;
const target = (event.target as Element | null)?.closest<HTMLElement>("[data-letter]");
const letter = all.find((item) => elements[item.index] === target);
if (!letter?.body) return;
event.preventDefault();
if (letter.body.isStatic) Body.setStatic(letter.body, false);
const point = pointFrom(event);
const constraint = Constraint.create({
pointA: point,
bodyB: letter.body,
pointB: Vector.sub(point, letter.body.position),
length: 0,
stiffness: reduced ? 0.4 : 0.18,
damping: 0.12,
});
Composite.add(engine.world, constraint);
grab = { constraint, letter, pointerId: event.pointerId };
box.setPointerCapture(event.pointerId);
if (target) target.style.cursor = "grabbing";
};
const onPointerMove = (event: PointerEvent) => {
if (!grab || event.pointerId !== grab.pointerId) return;
grab.constraint.pointA = pointFrom(event);
};
const onPointerUp = (event: PointerEvent) => {
if (!grab || event.pointerId !== grab.pointerId) return;
if (box.hasPointerCapture(event.pointerId)) box.releasePointerCapture(event.pointerId);
release();
};
box.addEventListener("pointerdown", onPointerDown);
box.addEventListener("pointermove", onPointerMove);
box.addEventListener("pointerup", onPointerUp);
box.addEventListener("pointercancel", onPointerUp);
box.addEventListener("lostpointercapture", onPointerUp);
let resizeTimer = 0;
const resize = new ResizeObserver(() => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
if (box.clientWidth !== width || box.clientHeight !== height) layout(width === 0);
}, 120);
});
resize.observe(box);
const visibility = new IntersectionObserver(([entry]) => {
visible = entry?.isIntersecting ?? true;
last = performance.now();
});
visibility.observe(box);
resetRef.current = drop;
layout(true);
frame = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(frame);
window.clearTimeout(resizeTimer);
resize.disconnect();
visibility.disconnect();
box.removeEventListener("pointerdown", onPointerDown);
box.removeEventListener("pointermove", onPointerMove);
box.removeEventListener("pointerup", onPointerUp);
box.removeEventListener("pointercancel", onPointerUp);
box.removeEventListener("lostpointercapture", onPointerUp);
Events.off(engine, "collisionStart", onCollision);
resetRef.current = () => {};
Composite.clear(engine.world, false);
Engine.clear(engine);
elements.forEach((element) => {
element?.firstElementChild?.getAnimations().forEach((animation) => animation.cancel());
});
};
};
void start();
return () => {
cancelled = true;
cancelAnimationFrame(frame);
cleanupWorld();
};
// The world is rebuilt when the letters themselves change; everything else is read live
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [word, fontWeight, fontFamily, motionKey]);
return (
<div
ref={boxRef}
className={`relative h-full w-full overflow-hidden select-none ${className}`}
style={{ touchAction: "pan-y", ...(fontFamily !== "inherit" ? { fontFamily } : null) }}
>
{/* What a screen reader reads: the word itself, once */}
<span className="sr-only">{word}</span>
<div aria-hidden="true">
{letters.map(({ char, position }, index) => {
const accent = accentEvery > 0 && (index + 1) % accentEvery === 0;
return (
<div
key={`${position}-${char}`}
ref={(element) => {
letterRefs.current[index] = element;
}}
data-letter=""
className="absolute top-0 left-0 cursor-grab"
style={{ visibility: "hidden", touchAction: "none", willChange: "transform", transformOrigin: "50% 50%" }}
>
<svg className="block h-full w-full overflow-visible" preserveAspectRatio="none">
<text fill={accent ? accentColour : letterColour} fontWeight={fontWeight} style={{ fontFamily: "inherit" }}>
{char}
</text>
</svg>
</div>
);
})}
</div>
{resetLabel ? (
<button
type="button"
onClick={() => resetRef.current()}
className={`absolute top-4 right-4 z-20 inline-flex cursor-pointer items-center gap-2 text-[13px] font-medium opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100 ${resetClassName}`}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 3-6.7" />
<path d="M3 4v5h5" />
</svg>
{resetLabel}
</button>
) : null}
</div>
);
}
The component. Installs to components/dimaac/physics-letters.tsx.
Install
With the shadcn CLI
npx shadcn@latest add https://ui.dimaac.com/r/physics-letters.jsonIt adds components/dimaac/physics-letters.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/physics-letters.
By hand
npm install matter-js- Install the packages it uses: matter-js.
- Copy the component file below into components/dimaac/physics-letters.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { PhysicsLetters } from "@/components/dimaac/physics-letters" and give its parent a size: it fills it.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | "DIMAAC" | The word. Spaces leave a gap in the row. Up to 24 characters. |
gravity | number | 1 | How hard the letters fall. 1 is normal, 3 is heavy. |
bounciness | number | 0.35 | How much a letter bounces when it lands, from 0 (dead) to 0.9 (rubber). |
dropStagger | number | 0.12 | The time between one letter dropping and the next, in seconds. |
fontWeight | number | 800 | The weight of the font, 500 to 900. Heavy weights look best as physics blocks. |
fontFamily | string | "inherit" | The font family. "inherit" uses the font of the box around it. |
letterColour | string | "currentColor" | The colour of the letters. "currentColor" follows the text colour around it. |
accentEvery | number | 0 | Paint every Nth letter in the accent colour. 0 paints none. |
accentColour | string | "#ddfc3e" | The colour of the accent letters. |
resetLabel | string | "Drop again" | The text of the button that drops the letters again. An empty string hides the button. |
resetClassName | string | "" | Extra classes for the reset button, e.g. to move it. |
className | string | "" | Extra classes for the outer box. Give it a size (it fills it). |
Use with AI
Agent prompt
Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.
Add the Physics Letters component (<PhysicsLetters />) from DiMaac UI to this project.
Docs: https://ui.dimaac.com/components/physics-letters
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/physics-letters.json
Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/physics-letters.json
Then use it like this:
import { PhysicsLetters } from "@/components/dimaac/physics-letters";
<PhysicsLetters />
If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/physics-letters.tsx and install matter-js.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txtFiles agents read
- llms.txt
- Every component and how to install it
- physics-letters.md
- This page as Markdown: install, props and the full source
- physics-letters.json
- The shadcn registry item
About
A word set in huge, heavy letters that fall in one by one with real Matter.js physics, land with a soft squash and a bounce, and settle into a loose pile on the floor. Grab any letter with the mouse or a finger and fling it: it swings from the spot you grabbed it and knocks the others over. The letters are real SVG text sized from the box, so the word nearly fills it at any width and stays crisp; screen readers read the word once from a hidden copy. The physics runs at a fixed 60 steps a second, pauses off screen, keeps the pile in place when the box resizes, and a Drop again button drops the letters once more. With reduced motion the letters sit settled in a neat row and can still be dragged, without being thrown.
- Installs to
components/dimaac/physics-letters.tsx- Uses
- matter-js
- Tags
- matter-js, physics, typography, drag, hero