NEMAWASHI LAB

Physics

Canvas

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

Controls
Font size80
Gravity0.50
Bounce0.55
Friction0.40
Body scale0.80
Text
Font
Ink
Background

Source

Physics.tsx184 lines
"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>
  );
}

NEMAWASHI — Kotaro Abe