{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gravity-letters",
  "title": "Gravity Letters",
  "description": "A playful gravity field where letters, numbers, emoji, or any components you pass fall and pile up like real objects.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/gravity-letters.tsx",
      "content": "\"use client\";\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type GlyphType = \"letters\" | \"numbers\" | \"both\";\n\nexport type GravityLettersProps = React.ComponentProps<\"div\"> & {\n  type?: GlyphType;\n  items?: React.ReactNode[];\n  gravity?: number;\n  size?: number;\n  color?: string;\n  maxGlyphs?: number;\n  deviceTilt?: boolean;\n};\n\nconst POOLS: Record<GlyphType, string> = {\n  letters: \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\",\n  numbers: \"0123456789\",\n  both: \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\",\n};\n\nconst COL = 8; // heightmap column width, px\nconst CLEARANCE = 24; // min air above the landing spot at spawn\nconst SLOPE = 0.35; // slide on when a neighbor sits this fraction of a glyph lower\nconst LEAVE_MS = 350;\nconst TILT = 26; // max rest tilt, deg\nconst BOUNCE = 0.22; // restitution of the first touch\nconst HOLD_MS = 300; // hold this long to start pouring\nconst POUR_MS = 120; // pour cadence while held\nconst TILT_ON = 10; // device tilt (deg) that starts an avalanche\nconst SHAKE_MS = 350; // min gap between tilt avalanches\nconst EAGER = 0.45; // slide-threshold factor while tilted\n\nconst rand = (min: number, max: number) => min + Math.random() * (max - min);\n\nconst clamp = (v: number, min: number, max: number) =>\n  Math.min(Math.max(v, min), max);\n\nconst randomChar = (type: GlyphType) => {\n  const pool = POOLS[type];\n  return pool[Math.floor(Math.random() * pool.length)];\n};\n\nconst pickContent = (items: React.ReactNode[] | undefined, type: GlyphType) =>\n  items && items.length > 0\n    ? items[Math.floor(Math.random() * items.length)]\n    : randomChar(type);\n\nconst spanOf = (x: number, w: number, cols: number) => {\n  const from = Math.max(0, Math.floor(x / COL));\n  const to = Math.min(cols - 1, Math.max(from, Math.ceil((x + w) / COL) - 1));\n  return [from, to] as const;\n};\n\nconst restY = (\n  heights: number[],\n  x: number,\n  w: number,\n  h: number,\n  rot: number,\n  height: number,\n) => {\n  const [from, to] = spanOf(x, w, heights.length);\n  const tan = Math.tan((Math.abs(rot) * Math.PI) / 180);\n  let y = Number.POSITIVE_INFINITY;\n  for (let i = from; i <= to; i++) {\n    const cx = clamp((i + 0.5) * COL - x, 0, w);\n    const edge = Math.min((rot >= 0 ? w - cx : cx) * tan, h - 1);\n    y = Math.min(y, height - heights[i] - h + edge);\n  }\n  return y;\n};\n\nconst deposit = (\n  heights: number[],\n  x: number,\n  w: number,\n  h: number,\n  rot: number,\n  y: number,\n  height: number,\n) => {\n  const [from, to] = spanOf(x, w, heights.length);\n  const tan = Math.tan((Math.abs(rot) * Math.PI) / 180);\n  for (let i = from; i <= to; i++) {\n    const cx = clamp((i + 0.5) * COL - x, 0, w);\n    const edge = Math.min((rot >= 0 ? cx : w - cx) * tan, h - 1);\n    heights[i] = Math.max(heights[i], height - y - edge);\n  }\n};\n\nconst windowTop = (heights: number[], from: number, to: number) => {\n  if (from < 0 || to >= heights.length) return Number.POSITIVE_INFINITY;\n  let top = 0;\n  for (let i = from; i <= to; i++) top = Math.max(top, heights[i]);\n  return top;\n};\n\nconst groundTilt = (heights: number[], x: number, w: number) => {\n  const [from, to] = spanOf(x, w, heights.length);\n  if (to <= from) return 0;\n  const mid = Math.ceil((from + to) / 2);\n  const hl = windowTop(heights, from, mid - 1);\n  const hr = windowTop(heights, mid, to);\n  if (!Number.isFinite(hl) || !Number.isFinite(hr)) return 0;\n  const run = Math.max(((to - from + 1) / 2) * COL, 1);\n  return (Math.atan2(hl - hr, run) * 180) / Math.PI;\n};\n\nconst findRestX = (\n  heights: number[],\n  x: number,\n  w: number,\n  h: number,\n  maxX: number,\n  bias: -1 | 1,\n  eager = 1,\n) => {\n  let cur = Math.min(Math.max(x, 0), maxX);\n  const drop = h * SLOPE * eager;\n  const step = Math.max(COL, Math.round(w / 3));\n  for (let i = 0; i < 64; i++) {\n    const [from, to] = spanOf(cur, w, heights.length);\n    const span = to - from + 1;\n    const top = windowTop(heights, from, to);\n    const dl = top - windowTop(heights, from - span, from - 1);\n    const dr = top - windowTop(heights, to + 1, to + span);\n\n    let next = cur;\n    if (dl > drop && dr > drop && Math.abs(dl - dr) <= 1) {\n      next = bias < 0 ? Math.max(cur - step, 0) : Math.min(cur + step, maxX);\n    } else if (dl > drop && dl >= dr) {\n      next = Math.max(cur - step, 0);\n    } else if (dr > drop) {\n      next = Math.min(cur + step, maxX);\n    }\n    if (next === cur) break;\n    cur = next;\n  }\n  return cur;\n};\n\ntype Glyph = {\n  id: number;\n  content: React.ReactNode;\n  fontSize: number;\n  x: number;\n  y: number;\n  still: boolean;\n  leaving?: boolean;\n};\n\ntype Body = {\n  el: HTMLSpanElement;\n  ew: number;\n  eh: number;\n  w: number;\n  h: number;\n  dx: number;\n  dy: number;\n  x0: number;\n  y0: number;\n  x: number;\n  y: number;\n  targetX: number;\n  targetY: number;\n  vy: number;\n  spin: number;\n  rot: number;\n  vr: number;\n  restRot: number;\n  sway: number;\n  bounced: boolean;\n  done: boolean;\n};\n\nconst paint = (body: Body) => {\n  body.el.style.transform = `translate3d(${body.x + body.dx}px, ${body.y + body.dy}px, 0) rotate(${body.rot}deg)`;\n};\n\nconst squash = (body: Body) => {\n  body.el.firstElementChild?.animate(\n    [{ transform: \"scaleY(0.82)\" }, { transform: \"scaleY(1)\" }],\n    { duration: 160, easing: \"cubic-bezier(0.215, 0.61, 0.355, 1)\" },\n  );\n};\n\nfunction useFallingGlyphs(opts: {\n  gravity: number;\n  maxGlyphs: number;\n  deviceTilt: boolean;\n}) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const bodiesRef = useRef(new Map<number, Body>());\n  const refsRef = useRef(\n    new Map<number, (el: HTMLSpanElement | null) => void>(),\n  );\n  const heightsRef = useRef<number[]>([]);\n  const rafRef = useRef(0);\n  const lastRef = useRef(0);\n  const timeoutsRef = useRef(new Set<ReturnType<typeof setTimeout>>());\n  const idRef = useRef(0);\n  const windRef = useRef<0 | -1 | 1>(0);\n  const lastShakeRef = useRef(0);\n  const armedRef = useRef(false);\n  const [tiltReady, setTiltReady] = useState(false);\n  const [glyphs, setGlyphs] = useState<Glyph[]>([]);\n\n  const optsRef = useRef(opts);\n  useEffect(() => {\n    optsRef.current = opts;\n  }, [opts]);\n\n  const retarget = (body: Body, width: number, height: number) => {\n    const heights = heightsRef.current;\n    const wind = windRef.current;\n    const bias = wind !== 0 ? wind : Math.random() < 0.5 ? -1 : (1 as const);\n    const seekX = findRestX(\n      heights,\n      body.x,\n      body.ew,\n      body.eh,\n      Math.max(width - body.ew, 0),\n      bias,\n      wind !== 0 ? EAGER : 1,\n    );\n\n    const squareness = Math.min(1, body.eh / body.ew);\n    const jitter = rand(-1, 1) * (2 + 8 * squareness);\n    const restRot = clamp(\n      groundTilt(heights, seekX, body.ew) + jitter,\n      -TILT,\n      TILT,\n    );\n    const rad = (Math.abs(restRot) * Math.PI) / 180;\n    body.restRot = restRot;\n    body.w = body.ew * Math.cos(rad) + body.eh * Math.sin(rad);\n    body.h = body.ew * Math.sin(rad) + body.eh * Math.cos(rad);\n    body.dx = (body.w - body.ew) / 2;\n    body.dy = (body.h - body.eh) / 2;\n\n    const targetX = clamp(seekX - body.dx, 0, Math.max(width - body.w, 0));\n    const targetY = restY(heights, targetX, body.w, body.h, restRot, height);\n    deposit(heights, targetX, body.w, body.h, restRot, targetY, height);\n    body.x0 = body.x;\n    body.y0 = Math.min(body.y, targetY);\n    body.targetX = targetX;\n    body.targetY = targetY;\n    body.sway = rand(-1, 1) * Math.min(12, (targetY - body.y0) * 0.05);\n    body.bounced = false;\n    body.done = false;\n  };\n\n  const rebuild = useCallback((slide = false) => {\n    const container = containerRef.current;\n    if (!container) return;\n    const width = container.clientWidth;\n    const height = container.clientHeight;\n    const cols = Math.max(1, Math.ceil(width / COL));\n    const heights = (heightsRef.current = new Array<number>(cols).fill(0));\n\n    const bodies = [...bodiesRef.current.values()].sort((a, b) => b.y - a.y);\n    for (const body of bodies) {\n      body.spin = body.rot;\n      if (!body.done) {\n        retarget(body, width, height);\n        continue;\n      }\n      const rest = restY(heights, body.x, body.w, body.h, body.restRot, height);\n      let falls = body.y < rest - 1;\n      if (!falls && slide) {\n        const dir = windRef.current || 1;\n        const probe = findRestX(\n          heights,\n          body.x,\n          body.ew,\n          body.eh,\n          Math.max(width - body.ew, 0),\n          dir,\n          EAGER,\n        );\n        const candX = clamp(probe - body.dx, 0, Math.max(width - body.w, 0));\n        const candY = restY(heights, candX, body.w, body.h, body.restRot, height);\n        falls = candY > body.y + 2;\n        if (falls) body.vr = rand(-40, 40);\n      }\n      if (falls) {\n        body.vy = 0;\n        retarget(body, width, height);\n      } else {\n        deposit(heights, body.x, body.w, body.h, body.restRot, body.y, height);\n      }\n    }\n  }, []);\n\n  const step = useCallback(function frame(now: number) {\n    const { gravity } = optsRef.current;\n    const dt = Math.max(0, Math.min((now - lastRef.current) / 1000, 1 / 30));\n    lastRef.current = now;\n\n    let active = false;\n    for (const body of bodiesRef.current.values()) {\n      if (body.done) continue;\n\n      body.vy += gravity * dt;\n      body.y += body.vy * dt;\n      body.spin += body.vr * dt;\n\n      const total = body.targetY - body.y0;\n      const p = total > 0 ? Math.min((body.y - body.y0) / total, 1) : 1;\n      body.x =\n        body.x0 +\n        (body.targetX - body.x0) * p * (2 - p) +\n        Math.sin(p * Math.PI) * body.sway;\n      const blend = p * p * p;\n      body.rot = body.spin * (1 - blend) + body.restRot * blend;\n\n      if (body.y >= body.targetY) {\n        body.x = body.targetX;\n        body.y = body.targetY;\n        body.rot = body.restRot;\n\n        const rebound = body.vy * BOUNCE;\n        if (!body.bounced && rebound * rebound > gravity * 6) {\n          body.bounced = true;\n          body.vy = -rebound;\n          body.x0 = body.targetX;\n          body.y0 = body.targetY;\n          body.sway = 0;\n          body.vr = 0;\n          body.spin = body.restRot;\n          paint(body);\n          squash(body);\n          active = true;\n          continue;\n        }\n\n        body.done = true;\n        paint(body);\n        if (!body.bounced) squash(body);\n        continue;\n      }\n\n      paint(body);\n      active = true;\n    }\n\n    rafRef.current = active ? requestAnimationFrame(frame) : 0;\n  }, []);\n\n  const wake = useCallback(() => {\n    if (rafRef.current) return;\n    lastRef.current = performance.now();\n    rafRef.current = requestAnimationFrame(step);\n  }, [step]);\n\n  const armTilt = useCallback(() => {\n    if (armedRef.current) return;\n    armedRef.current = true;\n    const DOE = window.DeviceOrientationEvent as unknown as\n      | { requestPermission?: () => Promise<string> }\n      | undefined;\n    if (typeof DOE?.requestPermission !== \"function\") return;\n    DOE.requestPermission()\n      .then((state) => state === \"granted\" && setTiltReady(true))\n      .catch(() => {});\n  }, []);\n\n  useEffect(() => {\n    const DOE = window.DeviceOrientationEvent as unknown as\n      | { requestPermission?: () => Promise<string> }\n      | undefined;\n    if (DOE && typeof DOE.requestPermission !== \"function\") setTiltReady(true);\n  }, []);\n\n  const tiltEnabled = opts.deviceTilt;\n  useEffect(() => {\n    if (!tiltEnabled || !tiltReady) return;\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n\n    const onTilt = (event: DeviceOrientationEvent) => {\n      const gamma = event.gamma ?? 0;\n      windRef.current = gamma > TILT_ON ? 1 : gamma < -TILT_ON ? -1 : 0;\n      if (\n        windRef.current !== 0 &&\n        performance.now() - lastShakeRef.current > SHAKE_MS\n      ) {\n        lastShakeRef.current = performance.now();\n        rebuild(true);\n        wake();\n      }\n    };\n    window.addEventListener(\"deviceorientation\", onTilt);\n    return () => {\n      windRef.current = 0;\n      window.removeEventListener(\"deviceorientation\", onTilt);\n    };\n  }, [tiltEnabled, tiltReady, rebuild, wake]);\n\n  const attach = useCallback(\n    (glyph: Glyph, el: HTMLSpanElement) => {\n      const existing = bodiesRef.current.get(glyph.id);\n      if (existing) {\n        existing.el = el;\n        paint(existing);\n        return;\n      }\n\n      const container = containerRef.current;\n      const width = container?.clientWidth ?? 0;\n      const height = container?.clientHeight ?? 0;\n      if (heightsRef.current.length !== Math.max(1, Math.ceil(width / COL))) {\n        rebuild();\n      }\n\n      const ew = el.offsetWidth || 1;\n      const eh = el.offsetHeight || 1;\n      const squareness = Math.min(1, eh / ew);\n      const body: Body = {\n        el,\n        ew,\n        eh,\n        w: ew,\n        h: eh,\n        dx: 0,\n        dy: 0,\n        x0: 0,\n        y0: 0,\n        x: Math.min(Math.max(glyph.x - ew / 2, 0), Math.max(width - ew, 0)),\n        y: glyph.y - eh / 2,\n        targetX: 0,\n        targetY: 0,\n        vy: 0,\n        spin: glyph.still ? 0 : rand(-1, 1) * (4 + 10 * squareness),\n        rot: 0,\n        vr: glyph.still ? 0 : rand(-1, 1) * (50 + 130 * squareness),\n        restRot: 0,\n        sway: 0,\n        bounced: false,\n        done: false,\n      };\n      retarget(body, width, height);\n\n      if (glyph.still) {\n        body.x = body.x0 = body.targetX;\n        body.y = body.y0 = body.targetY;\n        body.rot = body.restRot;\n        body.done = true;\n      } else {\n        body.rot = body.spin;\n        let startY = Math.min(body.y, body.targetY - CLEARANCE);\n        for (const other of bodiesRef.current.values()) {\n          const overlaps =\n            other.targetX < body.targetX + body.w &&\n            body.targetX < other.targetX + other.w;\n          if (!other.done && overlaps) {\n            startY = Math.min(startY, other.y - body.h - 8);\n          }\n        }\n        body.y = body.y0 = startY;\n      }\n\n      bodiesRef.current.set(glyph.id, body);\n      paint(body);\n\n      if (!glyph.still) {\n        el.firstElementChild?.animate(\n          [\n            { transform: \"scale(0.5)\", opacity: 0.3 },\n            { transform: \"scale(1)\", opacity: 1 },\n          ],\n          { duration: 150, easing: \"cubic-bezier(0.215, 0.61, 0.355, 1)\" },\n        );\n      }\n    },\n    [rebuild],\n  );\n\n  const glyphRef = (glyph: Glyph) => {\n    let callback = refsRef.current.get(glyph.id);\n    if (!callback) {\n      callback = (el) => {\n        if (el) attach(glyph, el);\n      };\n      refsRef.current.set(glyph.id, callback);\n    }\n    return callback;\n  };\n\n  const addGlyph = (spawn: Omit<Glyph, \"id\">) => {\n    const glyph: Glyph = { ...spawn, id: idRef.current++ };\n    setGlyphs((prev) => {\n      const next = [...prev, glyph];\n      const overflow = next.filter((g) => !g.leaving).length - opts.maxGlyphs;\n      if (overflow <= 0) return next;\n\n      let marked = 0;\n      return next.map((g) => {\n        if (marked < overflow && !g.leaving) {\n          marked++;\n          return { ...g, leaving: true };\n        }\n        return g;\n      });\n    });\n  };\n\n  useEffect(() => {\n    const alive = new Set(glyphs.map((g) => g.id));\n    let removed = false;\n    for (const id of bodiesRef.current.keys()) {\n      if (!alive.has(id)) {\n        bodiesRef.current.delete(id);\n        refsRef.current.delete(id);\n        removed = true;\n      }\n    }\n    if (removed) rebuild();\n\n    const leaving = glyphs.filter((g) => g.leaving).map((g) => g.id);\n    if (leaving.length > 0) {\n      const ids = new Set(leaving);\n      const timeout = setTimeout(() => {\n        timeoutsRef.current.delete(timeout);\n        setGlyphs((current) => current.filter((g) => !ids.has(g.id)));\n      }, LEAVE_MS);\n      timeoutsRef.current.add(timeout);\n    }\n\n    if (glyphs.length > 0) wake();\n  }, [glyphs, wake, rebuild]);\n\n  useEffect(() => {\n    const timeouts = timeoutsRef.current;\n    return () => {\n      cancelAnimationFrame(rafRef.current);\n      rafRef.current = 0;\n      timeouts.forEach(clearTimeout);\n    };\n  }, []);\n\n  return { containerRef, glyphs, glyphRef, addGlyph, armTilt };\n}\n\ntype Pour = {\n  id: number;\n  x: number;\n  y: number;\n  hold: ReturnType<typeof setTimeout> | null;\n  timer: ReturnType<typeof setInterval> | null;\n};\n\nconst GravityLetters = ({\n  type = \"letters\",\n  items,\n  gravity = 800,\n  size = 28,\n  color,\n  maxGlyphs = Infinity,\n  deviceTilt = true,\n  className,\n  children,\n  onPointerDown,\n  onPointerMove,\n  onPointerUp,\n  onPointerCancel,\n  ...props\n}: GravityLettersProps) => {\n  const { containerRef, glyphs, glyphRef, addGlyph, armTilt } =\n    useFallingGlyphs({ gravity, maxGlyphs, deviceTilt });\n  const pourRef = useRef<Pour | null>(null);\n\n  const dropAt = (clientX: number, clientY: number) => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const rect = container.getBoundingClientRect();\n    const reduce = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\",\n    ).matches;\n\n    addGlyph({\n      content: pickContent(items, type),\n      fontSize: Math.round(size * rand(0.8, 1.2)),\n      x: clamp(clientX - rect.left, 0, rect.width),\n      y: clamp(clientY - rect.top, 0, rect.height),\n      still: reduce,\n    });\n  };\n\n  const stopPour = () => {\n    const pour = pourRef.current;\n    if (!pour) return;\n    if (pour.hold) clearTimeout(pour.hold);\n    if (pour.timer) clearInterval(pour.timer);\n    pourRef.current = null;\n  };\n\n  const startPour = (event: React.PointerEvent<HTMLDivElement>) => {\n    stopPour();\n    const pour: Pour = {\n      id: event.pointerId,\n      x: event.clientX,\n      y: event.clientY,\n      hold: null,\n      timer: null,\n    };\n    pour.hold = setTimeout(() => {\n      pour.timer = setInterval(() => {\n        dropAt(pour.x + rand(-8, 8), pour.y);\n      }, POUR_MS);\n    }, HOLD_MS);\n    pourRef.current = pour;\n  };\n\n  useEffect(() => {\n    return () => {\n      const pour = pourRef.current;\n      if (pour?.hold) clearTimeout(pour.hold);\n      if (pour?.timer) clearInterval(pour.timer);\n    };\n  }, []);\n\n  return (\n    <div\n      ref={containerRef}\n      data-slot=\"gravity-letters\"\n      className={cn(\n        \"relative touch-manipulation overflow-hidden select-none\",\n        className,\n      )}\n      onPointerDown={(event) => {\n        onPointerDown?.(event);\n        if (event.button !== 0 || event.defaultPrevented) return;\n        armTilt();\n        try {\n          event.currentTarget.setPointerCapture(event.pointerId);\n        } catch {\n          // capture is optional\n        }\n        dropAt(event.clientX, event.clientY);\n        startPour(event);\n      }}\n      onPointerMove={(event) => {\n        onPointerMove?.(event);\n        const pour = pourRef.current;\n        if (pour && event.pointerId === pour.id) {\n          pour.x = event.clientX;\n          pour.y = event.clientY;\n        }\n      }}\n      onPointerUp={(event) => {\n        onPointerUp?.(event);\n        if (pourRef.current?.id === event.pointerId) stopPour();\n      }}\n      onPointerCancel={(event) => {\n        onPointerCancel?.(event);\n        if (pourRef.current?.id === event.pointerId) stopPour();\n      }}\n      {...props}\n    >\n      {children}\n      <div aria-hidden className=\"pointer-events-none absolute inset-0\">\n        {glyphs.map((glyph) => (\n          <span\n            key={glyph.id}\n            ref={glyphRef(glyph)}\n            className=\"absolute top-0 left-0 font-semibold transition-opacity duration-300 will-change-transform\"\n            style={{\n              fontSize: glyph.fontSize,\n              lineHeight: 1,\n              color,\n              opacity: glyph.leaving ? 0 : 1,\n            }}\n          >\n            <span className=\"inline-block origin-bottom\">{glyph.content}</span>\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n};\n\nexport default GravityLetters;\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}