Matter.js rigid-body simulation where each character of the input text is a chamfered rectangle body with configurable gravity, bounce, and friction. The engine runs via Matter.Runner; a separate requestAnimationFrame loop reads body positions and angles and writes CSS transforms to DOM spans, keeping the rendering decoupled from the physics tick. Clicking anywhere spawns a fresh copy of the full string at the pointer, so the viewport gradually fills with tumbling letterforms.
Physics
click anywhere to spawn
Source
"use client";
import { useEffect, useRef, useState } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelColor, PanelSelect, PanelText } from "../_kansei/controls";
import { resolveFont, type FontChoice } from "../_kansei/fonts";
const FAMILY: Record<FontChoice, string> = {
display: "var(--font-display)",
sans: "var(--font-geist-sans)",
noto: "var(--font-noto-sans-jp)",
};
function isDark(hex: string) {
const n = parseInt(hex.slice(1), 16);
return (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255 < 0.5;
}
export default function Physics() {
const [text, setText] = useState("Kansei");
const [fontSize, setFontSize] = useState(80);
const [font, setFont] = useState<FontChoice>("sans");
const [gravity, setGravity] = useState(0.5);
const [restitution, setRestitution] = useState(0.55);
const [friction, setFriction] = useState(0.4);
const [bodyScale, setBodyScale] = useState(0.8);
const [inkColor, setInkColor] = useState("#1a1814");
const [bgColor, setBgColor] = useState("#f4efe6");
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let cancelled = false;
let raf = 0;
let cleanupSim: (() => void) | null = null;
(async () => {
await document.fonts.ready;
if (cancelled) return;
const W = container.offsetWidth || 800;
const H = container.offsetHeight || 400;
container.innerHTML = "";
const canvasFont = resolveFont(font);
const cssFontFamily = FAMILY[font];
const offCtx = document.createElement("canvas").getContext("2d")!;
const words = text.split("").filter((c) => c.trim());
const measure = (word: string) => {
offCtx.font = `700 ${fontSize}px ${canvasFont}`;
return {
w: (offCtx.measureText(word).width + fontSize * 0.28) * bodyScale,
h: fontSize * 0.88 * bodyScale,
};
};
const Matter = await import("matter-js");
if (cancelled) return;
const { Engine, Runner, Bodies, Body, World } = Matter;
const engine = Engine.create({ gravity: { x: 0, y: gravity } });
const runner = Runner.create();
const T = 80;
const floorY = H - fontSize * bodyScale * 0.44 + T / 2;
World.add(engine.world, [
Bodies.rectangle(W / 2, floorY, W + T * 2, T, { isStatic: true, friction: 1 }),
Bodies.rectangle(-T / 2, H / 2, T, H * 3, { isStatic: true }),
Bodies.rectangle(W + T / 2, H / 2, T, H * 3, { isStatic: true }),
]);
const elMap = new Map<number, HTMLElement>();
const spawnWord = (word: string, x: number, y: number) => {
const { w, h } = measure(word);
const body = Bodies.rectangle(x, y, w, h, {
restitution,
friction,
frictionAir: 0.01,
chamfer: { radius: h * 0.22 },
angle: (Math.random() - 0.5) * 0.35,
});
Body.setVelocity(body, { x: (Math.random() - 0.5) * 3, y: 0 });
const el = document.createElement("span");
el.textContent = word;
el.style.cssText = [
"position:absolute", "left:0;top:0",
`font-size:${fontSize}px`, "font-weight:700",
`font-family:${cssFontFamily},sans-serif`,
`color:${inkColor}`, "white-space:nowrap", "pointer-events:none",
"user-select:none", "will-change:transform", "transform-origin:center",
].join(";");
container.appendChild(el);
elMap.set(body.id, el);
World.add(engine.world, body);
};
const spawnAll = (cx: number, cy: number) => {
words.forEach((w, i) => {
const { w: bw } = measure(w);
spawnWord(w, cx + (i % 6) * (bw + 8), cy - Math.floor(i / 6) * (fontSize * 1.1));
});
};
spawnAll(W * 0.2, -fontSize * 0.5);
Runner.run(runner, engine);
const renderLoop = () => {
if (cancelled) return;
for (const body of engine.world.bodies) {
if (body.isStatic) continue;
const el = elMap.get(body.id);
if (!el) continue;
const { x, y } = body.position;
el.style.transform = `translate(calc(${x}px - 50%), calc(${y}px - 50%)) rotate(${body.angle}rad)`;
}
raf = requestAnimationFrame(renderLoop);
};
raf = requestAnimationFrame(renderLoop);
const handleClick = (e: MouseEvent) => {
const rect = container.getBoundingClientRect();
spawnAll(e.clientX - rect.left - words.length * (fontSize * 0.3), e.clientY - rect.top);
};
container.addEventListener("click", handleClick);
cleanupSim = () => {
Runner.stop(runner);
World.clear(engine.world, false);
Engine.clear(engine);
container.removeEventListener("click", handleClick);
};
})();
return () => {
cancelled = true;
cancelAnimationFrame(raf);
cleanupSim?.();
container.innerHTML = "";
};
}, [text, fontSize, font, gravity, restitution, friction, bodyScale, inkColor]);
const dark = isDark(bgColor);
return (
<div className="fixed inset-0 overflow-hidden select-none" style={{ background: bgColor }}>
<div ref={containerRef} className="absolute inset-0 cursor-crosshair z-[1]" />
<div
className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
>
<p className="text-[12px] tracking-[0.08em] uppercase" style={{ color: dark ? "rgba(255,255,255,0.55)" : "rgba(23,23,23,0.5)" }}>Physics</p>
<p className="text-[12px] tracking-[0.08em] mt-1" style={{ color: dark ? "rgba(255,255,255,0.3)" : "rgba(23,23,23,0.3)" }}>click anywhere to spawn</p>
</div>
<DraggablePanel
isLight={dark}
controls={[
{ label: "Font size", value: fontSize, set: (v) => setFontSize(Math.round(v)), min: 32, max: 160, step: 1 },
{ label: "Gravity", value: gravity, set: setGravity, min: 0, max: 2, step: 0.05 },
{ label: "Bounce", value: restitution, set: setRestitution, min: 0, max: 1, step: 0.05 },
{ label: "Friction", value: friction, set: setFriction, min: 0, max: 1, step: 0.05 },
{ label: "Body scale", value: bodyScale, set: setBodyScale, min: 0.4, max: 1.4, step: 0.05 },
]}
>
<PanelText label="Text" value={text} set={setText} placeholder="Kansei" isLight={dark} />
<PanelSelect label="Font" value={font} options={["display", "sans", "noto"] as const} set={setFont} isLight={dark} />
<PanelColor label="Ink" value={inkColor} set={setInkColor} isLight={dark} />
<PanelColor label="Background" value={bgColor} set={setBgColor} isLight={dark} />
</DraggablePanel>
<div className="fixed top-8 left-8 z-10">
<ScrambleLink
from="NEMAWASHI LAB"
to="← BACK"
href="/lab"
className={`text-[14px] tracking-[0.08em] uppercase pointer-events-auto font-[family-name:var(--font-flux)] ${dark ? "text-white/60" : "text-foreground/60"}`}
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
/>
</div>
</div>
);
}