← Components

Physics Letters

Huge letters that drop in, bounce, pile up and can be thrown.

Text · FreeInstall ↓
Open preview ↗
Loading the demoNew tab ↗

Customize

1.0
0.35
0.12 s
800
0
Your code
<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.json

It 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
  1. Install the packages it uses: matter-js.
  2. Copy the component file below into components/dimaac/physics-letters.tsx.
  3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
  4. Import it with import { PhysicsLetters } from "@/components/dimaac/physics-letters" and give its parent a size: it fills it.

Props

PropTypeDefaultDescription
textstring"DIMAAC"The word. Spaces leave a gap in the row. Up to 24 characters.
gravitynumber1How hard the letters fall. 1 is normal, 3 is heavy.
bouncinessnumber0.35How much a letter bounces when it lands, from 0 (dead) to 0.9 (rubber).
dropStaggernumber0.12The time between one letter dropping and the next, in seconds.
fontWeightnumber800The weight of the font, 500 to 900. Heavy weights look best as physics blocks.
fontFamilystring"inherit"The font family. "inherit" uses the font of the box around it.
letterColourstring"currentColor"The colour of the letters. "currentColor" follows the text colour around it.
accentEverynumber0Paint every Nth letter in the accent colour. 0 paints none.
accentColourstring"#ddfc3e"The colour of the accent letters.
resetLabelstring"Drop again"The text of the button that drops the letters again. An empty string hides the button.
resetClassNamestring""Extra classes for the reset button, e.g. to move it.
classNamestring""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.

Prompt
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.txt

Files 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