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