{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "falling-text",
  "type": "registry:ui",
  "title": "Falling Text",
  "description": "A sentence whose words or letters break loose and fall with real 2D physics — then can be dragged, thrown and reset.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/falling-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type FallingTextTrigger = \"click\" | \"hover\" | \"view\";\nexport type FallingTextSplit = \"words\" | \"letters\";\n\nexport interface FallingTextHandle {\n  /** Detach the pieces and let them fall. */\n  drop: () => void;\n  /** Return every piece to its place in the sentence. */\n  reset: () => void;\n}\n\nexport interface FallingTextProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** The sentence. Exposed once to assistive technology. */\n  text?: string;\n  /** Pieces that fall: whole words or single letters. */\n  split?: FallingTextSplit;\n  /** What makes the sentence fall. The built-in button always works too. */\n  trigger?: FallingTextTrigger;\n  /** Gravity multiplier (1 ≈ 2400 px/s²). */\n  gravity?: number;\n  /** Restitution: 0 = dead stop, 0.8 = very bouncy. */\n  bounce?: number;\n  /** Let fallen pieces be dragged and thrown with the pointer. */\n  draggable?: boolean;\n  /** Draw the last word in the accent colour. */\n  accentLast?: boolean;\n  /** Accent colour (any CSS colour). */\n  accent?: string;\n  /** Height of the physics box in px (its floor). */\n  height?: number;\n  /** Show the Drop / Reset button. */\n  showControl?: boolean;\n  /** Changing this value resets the sentence. */\n  resetKey?: string | number;\n  /** Element used for the visually hidden sentence. */\n  as?: \"p\" | \"h2\" | \"h3\" | \"span\";\n  /** Imperative handle with `drop()` and `reset()`. */\n  ref?: React.Ref<FallingTextHandle>;\n  onFallChange?: (fallen: boolean) => void;\n}\n\n/* SSR-safe: renders \"no preference\" on the server and the first client pass, so markup always hydrates. */\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* --------------------------------- physics -------------------------------- */\n\n/** A capsule: a segment of half-length `hw` along the body's angle, thickened by radius `r`. */\ninterface Body {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  angle: number;\n  spin: number;\n  hw: number;\n  r: number;\n  invMass: number;\n  invInertia: number;\n  /** Resting centre in the sentence, for writing relative transforms. */\n  x0: number;\n  y0: number;\n}\n\ninterface Params {\n  gravity: number;\n  bounce: number;\n}\n\ninterface Grab {\n  index: number;\n  pointerId: number;\n  /** Pointer target for the body's centre, in box coordinates. */\n  tx: number;\n  ty: number;\n  dx: number;\n  dy: number;\n}\n\nconst BASE_GRAVITY = 2400; // px/s²\nconst STEP = 1 / 120;\nconst MAX_SPEED = 3200;\nconst FRICTION = 0.4;\nconst SLOP = 0.5;\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));\n\nfunction endpoints(b: Body) {\n  const ux = Math.cos(b.angle) * b.hw;\n  const uy = Math.sin(b.angle) * b.hw;\n  return [b.x - ux, b.y - uy, b.x + ux, b.y + uy] as const;\n}\n\n/** Closest points between segments p1–q1 and p2–q2 (Ericson, Real-Time Collision Detection 5.1.9). */\nfunction closestSegmentPoints(\n  p1x: number, p1y: number, q1x: number, q1y: number,\n  p2x: number, p2y: number, q2x: number, q2y: number,\n) {\n  const d1x = q1x - p1x, d1y = q1y - p1y, d2x = q2x - p2x, d2y = q2y - p2y;\n  const rx = p1x - p2x, ry = p1y - p2y;\n  const a = d1x * d1x + d1y * d1y, e = d2x * d2x + d2y * d2y, f = d2x * rx + d2y * ry;\n  let s = 0;\n  let t = 0;\n  if (a <= 1e-9 && e <= 1e-9) {\n    s = t = 0;\n  } else if (a <= 1e-9) {\n    t = clamp(f / e, 0, 1);\n  } else {\n    const c = d1x * rx + d1y * ry;\n    if (e <= 1e-9) {\n      s = clamp(-c / a, 0, 1);\n    } else {\n      const b = d1x * d2x + d1y * d2y;\n      const denom = a * e - b * b;\n      s = denom > 1e-9 ? clamp((b * f - c * e) / denom, 0, 1) : 0;\n      t = (b * s + f) / e;\n      if (t < 0) {\n        t = 0;\n        s = clamp(-c / a, 0, 1);\n      } else if (t > 1) {\n        t = 1;\n        s = clamp((b - c) / a, 0, 1);\n      }\n    }\n  }\n  return [p1x + d1x * s, p1y + d1y * s, p2x + d2x * t, p2y + d2y * t] as const;\n}\n\n/** Impulse response at contact point (cx, cy) with normal n pointing from A to B. B = null is the static box. */\nfunction resolve(A: Body, B: Body | null, nx: number, ny: number, cx: number, cy: number, depth: number, bounce: number) {\n  const rax = cx - A.x, ray = cy - A.y;\n  const rbx = B ? cx - B.x : 0, rby = B ? cy - B.y : 0;\n  // Relative velocity of B with respect to A at the contact.\n  const vax = A.vx - A.spin * ray, vay = A.vy + A.spin * rax;\n  const vbx = B ? B.vx - B.spin * rby : 0, vby = B ? B.vy + B.spin * rbx : 0;\n  const rvx = vbx - vax, rvy = vby - vay;\n  const vn = rvx * nx + rvy * ny;\n  const imB = B ? B.invMass : 0, iiB = B ? B.invInertia : 0;\n\n  if (vn < 0) {\n    const raN = rax * ny - ray * nx;\n    const rbN = rbx * ny - rby * nx;\n    const k = A.invMass + imB + raN * raN * A.invInertia + rbN * rbN * iiB;\n    // No restitution for slow contacts, so stacks come to rest instead of jittering.\n    const e = vn < -90 ? bounce : 0;\n    const j = (-(1 + e) * vn) / k;\n    applyImpulse(A, B, nx * j, ny * j, rax, ray, rbx, rby);\n\n    // Coulomb friction along the tangent.\n    let tx = rvx - vn * nx, ty = rvy - vn * ny;\n    const tl = Math.hypot(tx, ty);\n    if (tl > 1e-6) {\n      tx /= tl;\n      ty /= tl;\n      const raT = rax * ty - ray * tx;\n      const rbT = rbx * ty - rby * tx;\n      const kt = A.invMass + imB + raT * raT * A.invInertia + rbT * rbT * iiB;\n      const jt = clamp(-(rvx * tx + rvy * ty) / kt, -FRICTION * j, FRICTION * j);\n      applyImpulse(A, B, tx * jt, ty * jt, rax, ray, rbx, rby);\n    }\n  }\n\n  // Positional correction removes the remaining overlap, split by inverse mass.\n  const total = A.invMass + imB;\n  const correction = Math.max(depth - SLOP, 0) * 0.8 / (total || 1);\n  A.x -= nx * correction * A.invMass;\n  A.y -= ny * correction * A.invMass;\n  if (B) {\n    B.x += nx * correction * B.invMass;\n    B.y += ny * correction * B.invMass;\n  }\n}\n\nfunction applyImpulse(A: Body, B: Body | null, jx: number, jy: number, rax: number, ray: number, rbx: number, rby: number) {\n  A.vx -= jx * A.invMass;\n  A.vy -= jy * A.invMass;\n  A.spin -= (rax * jy - ray * jx) * A.invInertia;\n  if (B) {\n    B.vx += jx * B.invMass;\n    B.vy += jy * B.invMass;\n    B.spin += (rbx * jy - rby * jx) * B.invInertia;\n  }\n}\n\nfunction collideWalls(b: Body, width: number, height: number, bounce: number) {\n  const [ax, ay, bx, by] = endpoints(b);\n  const ends = b.hw > 0.5 ? [ax, ay, bx, by] : [b.x, b.y];\n  for (let i = 0; i < ends.length; i += 2) {\n    const ex = ends[i], ey = ends[i + 1];\n    if (ey + b.r > height) resolve(b, null, 0, 1, ex, ey + b.r, ey + b.r - height, bounce);\n    if (ey - b.r < 0) resolve(b, null, 0, -1, ex, ey - b.r, b.r - ey, bounce);\n    if (ex - b.r < 0) resolve(b, null, -1, 0, ex - b.r, ey, b.r - ex, bounce);\n    if (ex + b.r > width) resolve(b, null, 1, 0, ex + b.r, ey, ex + b.r - width, bounce);\n  }\n}\n\nfunction collidePair(A: Body, B: Body, bounce: number) {\n  const reach = A.hw + A.r + B.hw + B.r;\n  if (Math.abs(A.x - B.x) > reach || Math.abs(A.y - B.y) > reach) return;\n  const [a1x, a1y, a2x, a2y] = endpoints(A);\n  const [b1x, b1y, b2x, b2y] = endpoints(B);\n  const [px, py, qx, qy] = closestSegmentPoints(a1x, a1y, a2x, a2y, b1x, b1y, b2x, b2y);\n  let dx = qx - px, dy = qy - py;\n  const dist = Math.hypot(dx, dy);\n  const radii = A.r + B.r;\n  if (dist >= radii) return;\n  if (dist < 1e-6) {\n    dx = 0;\n    dy = 1;\n  } else {\n    dx /= dist;\n    dy /= dist;\n  }\n  resolve(A, B, dx, dy, px + dx * A.r, py + dy * A.r, radii - dist, bounce);\n}\n\nfunction step(bodies: Body[], grab: Grab | null, params: Params, width: number, height: number) {\n  const g = BASE_GRAVITY * params.gravity;\n  for (let i = 0; i < bodies.length; i++) {\n    const b = bodies[i];\n    if (grab && grab.index === i) {\n      // A stiff spring towards the pointer; the resulting velocity is kept on release (a throw).\n      b.vx = clamp((grab.tx - b.x) * 22, -MAX_SPEED, MAX_SPEED);\n      b.vy = clamp((grab.ty - b.y) * 22, -MAX_SPEED, MAX_SPEED);\n      b.spin *= 0.8;\n    } else {\n      b.vy += g * STEP;\n      b.vx *= 0.9995;\n      b.vy *= 0.9995;\n      b.spin *= 0.995;\n    }\n    b.x += b.vx * STEP;\n    b.y += b.vy * STEP;\n    b.angle += b.spin * STEP;\n  }\n  for (let iteration = 0; iteration < 4; iteration++) {\n    for (let i = 0; i < bodies.length; i++) {\n      for (let j = i + 1; j < bodies.length; j++) collidePair(bodies[i], bodies[j], params.bounce);\n      collideWalls(bodies[i], width, height, params.bounce);\n    }\n  }\n}\n\n/* -------------------------------- component ------------------------------- */\n\n/**\n * A sentence whose words or letters detach and fall into a box with lightweight 2D physics:\n * gravity, bounces off the floor and walls, rotation and capsule collisions between pieces.\n * Fallen pieces can be dragged and thrown. The sentence stays readable to screen readers.\n */\nexport function FallingText({\n  text = \"Gravity always wins in the end\",\n  split = \"words\",\n  trigger = \"click\",\n  gravity = 1,\n  bounce = 0.35,\n  draggable = true,\n  accentLast = true,\n  accent = \"#FF7448\",\n  height = 300,\n  showControl = true,\n  resetKey,\n  as = \"p\",\n  ref,\n  onFallChange,\n  className = \"\",\n  style,\n  onClick,\n  onPointerEnter,\n  onPointerDown,\n  ...props\n}: FallingTextProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const layerRef = React.useRef<HTMLDivElement>(null);\n  const paramsRef = React.useRef<Params>({ gravity, bounce });\n  const grabRef = React.useRef<Grab | null>(null);\n  const engineRef = React.useRef<{ wake: () => void; bodies: Body[] } | null>(null);\n  const [fallen, setFallen] = React.useState(false);\n  const [prevResetKey, setPrevResetKey] = React.useState(resetKey);\n\n  // \"Adjust state when a prop changes\": a new resetKey restores the sentence.\n  if (resetKey !== prevResetKey) {\n    setPrevResetKey(resetKey);\n    setFallen(false);\n  }\n\n  const active = fallen && !reduceMotion;\n\n  const setFall = (next: boolean) => {\n    if (next && reduceMotion) return;\n    setFallen(next);\n    onFallChange?.(next);\n  };\n\n  React.useImperativeHandle(ref, () => ({ drop: () => setFall(true), reset: () => setFall(false) }));\n\n  React.useEffect(() => {\n    paramsRef.current = { gravity: Math.max(0, gravity), bounce: clamp(bounce, 0, 0.95) };\n    engineRef.current?.wake();\n  }, [gravity, bounce]);\n\n  // Trigger \"view\": drop once the box is mostly visible.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root || trigger !== \"view\" || fallen || reduceMotion) return;\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((e) => e.intersectionRatio >= 0.6)) {\n          setFallen(true);\n          onFallChange?.(true);\n        }\n      },\n      { threshold: [0, 0.6] },\n    );\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [trigger, fallen, reduceMotion, onFallChange]);\n\n  // The simulation: runs while fallen, pauses offscreen, in hidden tabs and once everything rests.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const layer = layerRef.current;\n    if (!root || !layer) return;\n    const pieces = Array.from(layer.querySelectorAll<HTMLElement>(\"[data-fall-piece]\"));\n\n    if (!active) {\n      // Ease every piece back into the sentence.\n      for (const el of pieces) {\n        if (!el.style.transform) continue;\n        el.style.transition = \"transform 650ms cubic-bezier(0.22, 1, 0.36, 1)\";\n        el.style.transform = \"\";\n      }\n      return;\n    }\n\n    const box = root.getBoundingClientRect();\n    const originX = box.left + root.clientLeft;\n    const originY = box.top + root.clientTop;\n    let width = root.clientWidth;\n    let boxHeight = root.clientHeight;\n\n    const bodies: Body[] = pieces.map((el) => {\n      el.style.transition = \"none\";\n      el.style.transform = \"\";\n      const rect = el.getBoundingClientRect();\n      const w = Math.max(2, rect.width);\n      const h = Math.max(2, rect.height);\n      const r = Math.max(1, Math.min(h * 0.42, w / 2));\n      const mass = (w * h) / 400;\n      const x0 = rect.left - originX + w / 2;\n      const y0 = rect.top - originY + h / 2;\n      return {\n        x: x0,\n        y: y0,\n        // A small random nudge so the sentence breaks apart instead of falling as a block.\n        vx: (Math.random() - 0.5) * 120,\n        vy: -Math.random() * 80,\n        angle: 0,\n        spin: (Math.random() - 0.5) * 3,\n        hw: Math.max(0, w / 2 - r),\n        r,\n        invMass: 1 / mass,\n        invInertia: 12 / (mass * (w * w + h * h)),\n        x0,\n        y0,\n      };\n    });\n\n    let frame = 0;\n    let last = 0;\n    let acc = 0;\n    let resting = 0;\n    let inView = true;\n\n    const render = () => {\n      for (let i = 0; i < bodies.length; i++) {\n        const b = bodies[i];\n        pieces[i].style.transform = `translate(${b.x - b.x0}px, ${b.y - b.y0}px) rotate(${b.angle}rad)`;\n      }\n    };\n\n    const tick = (now: number) => {\n      frame = 0;\n      const elapsed = last ? Math.min(0.05, (now - last) / 1000) : STEP;\n      last = now;\n      acc += elapsed;\n      let steps = 0;\n      while (acc >= STEP && steps < 6) {\n        step(bodies, grabRef.current, paramsRef.current, width, boxHeight);\n        acc -= STEP;\n        steps++;\n      }\n      render();\n\n      // Sleep once nothing has moved noticeably for a while.\n      const moving = bodies.some((b) => Math.abs(b.vx) > 6 || Math.abs(b.vy) > 6 || Math.abs(b.spin) > 0.05);\n      resting = moving || grabRef.current ? 0 : resting + 1;\n      if (resting < 45) frame = requestAnimationFrame(tick);\n    };\n\n    const wake = () => {\n      resting = 0;\n      if (!frame && inView && !document.hidden) {\n        last = 0;\n        frame = requestAnimationFrame(tick);\n      }\n    };\n    const sleep = () => {\n      cancelAnimationFrame(frame);\n      frame = 0;\n    };\n    const sync = () => (inView && !document.hidden ? wake() : sleep());\n\n    const observer = new IntersectionObserver((entries) => {\n      inView = entries.some((e) => e.isIntersecting);\n      sync();\n    });\n    observer.observe(root);\n    const resize = new ResizeObserver(() => {\n      width = root.clientWidth;\n      boxHeight = root.clientHeight;\n      wake();\n    });\n    resize.observe(root);\n    document.addEventListener(\"visibilitychange\", sync);\n\n    engineRef.current = { wake, bodies };\n    wake();\n\n    return () => {\n      sleep();\n      observer.disconnect();\n      resize.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n      engineRef.current = null;\n      grabRef.current = null;\n    };\n  }, [active, text, split]);\n\n  /* ------------------------------ pointer drag ----------------------------- */\n\n  const toBox = (event: React.PointerEvent) => {\n    const root = rootRef.current;\n    if (!root) return { x: 0, y: 0 };\n    const rect = root.getBoundingClientRect();\n    return { x: event.clientX - rect.left - root.clientLeft, y: event.clientY - rect.top - root.clientTop };\n  };\n\n  const handleLayerPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    const engine = engineRef.current;\n    if (!active || !draggable || !engine) return;\n    const piece = (event.target as HTMLElement).closest<HTMLElement>(\"[data-fall-piece]\");\n    if (!piece) return;\n    const index = Number(piece.dataset.fallPiece);\n    const body = engine.bodies[index];\n    if (!body) return;\n    event.preventDefault();\n    const p = toBox(event);\n    grabRef.current = { index, pointerId: event.pointerId, dx: body.x - p.x, dy: body.y - p.y, tx: body.x, ty: body.y };\n    event.currentTarget.setPointerCapture(event.pointerId);\n    engine.wake();\n  };\n\n  const handleLayerPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const grab = grabRef.current;\n    if (!grab || grab.pointerId !== event.pointerId) return;\n    const p = toBox(event);\n    grab.tx = p.x + grab.dx;\n    grab.ty = p.y + grab.dy;\n  };\n\n  const release = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (grabRef.current?.pointerId === event.pointerId) grabRef.current = null;\n  };\n\n  /* -------------------------------- triggers ------------------------------- */\n\n  const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {\n    onClick?.(event);\n    if (trigger === \"click\" && !fallen) setFall(true);\n  };\n  const handlePointerEnter = (event: React.PointerEvent<HTMLDivElement>) => {\n    onPointerEnter?.(event);\n    if (trigger === \"hover\" && event.pointerType === \"mouse\" && !fallen) setFall(true);\n  };\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    onPointerDown?.(event);\n    // Touch has no hover: a tap drops the sentence instead.\n    if (trigger === \"hover\" && event.pointerType !== \"mouse\" && !fallen) setFall(true);\n  };\n\n  const words = text.split(/\\s+/).filter(Boolean);\n  let pieceIndex = 0;\n  const Tag = as;\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative w-full overflow-hidden rounded-xl border border-zinc-900/10 text-zinc-900 [--fall-ink:color-mix(in_oklab,var(--fall-accent)_78%,#18181b)] dark:border-white/10 dark:text-zinc-100 dark:[--fall-ink:var(--fall-accent)] ${\n        trigger === \"click\" && !active ? \"cursor-pointer\" : \"\"\n      } ${className}`}\n      style={{ [\"--fall-accent\" as string]: accent, height, ...style }}\n      onClick={handleClick}\n      onPointerEnter={handlePointerEnter}\n      onPointerDown={handlePointerDown}\n      {...props}\n    >\n      <Tag className=\"sr-only\">{text}</Tag>\n\n      {/* The physics layer is decorative: the sentence above is what assistive technology reads. */}\n      <div\n        ref={layerRef}\n        aria-hidden=\"true\"\n        className={`relative select-none p-6 ${showControl ? \"pt-14\" : \"\"} ${active && draggable ? \"touch-none\" : \"\"}`}\n        onPointerDown={handleLayerPointerDown}\n        onPointerMove={handleLayerPointerMove}\n        onPointerUp={release}\n        onPointerCancel={release}\n      >\n        {words.map((word, w) => {\n          const accented = accentLast && w === words.length - 1;\n          const tone = accented ? \"text-[var(--fall-ink)]\" : \"\";\n          const pieceClass = `inline-block will-change-transform ${active && draggable ? \"cursor-grab active:cursor-grabbing\" : \"\"}`;\n          return (\n            <React.Fragment key={w}>\n              {w > 0 ? \" \" : null}\n              {split === \"letters\" ? (\n                <span className={`inline-block whitespace-nowrap ${tone}`}>\n                  {Array.from(word).map((letter, l) => (\n                    <span key={l} data-fall-piece={pieceIndex++} className={pieceClass}>\n                      {letter}\n                    </span>\n                  ))}\n                </span>\n              ) : (\n                <span data-fall-piece={pieceIndex++} className={`${pieceClass} ${tone}`}>\n                  {word}\n                </span>\n              )}\n            </React.Fragment>\n          );\n        })}\n      </div>\n\n      {/* With reduced motion nothing falls, so the control is not offered. */}\n      {showControl && !reduceMotion ? (\n        <button\n          type=\"button\"\n          onClick={(event) => {\n            event.stopPropagation();\n            setFall(!active);\n          }}\n          aria-label={active ? \"Reset the sentence\" : \"Drop the words\"}\n          className=\"absolute right-3 top-3 inline-flex h-8 items-center gap-1.5 rounded-full border border-zinc-900/10 bg-white/80 px-3 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-600 outline-hidden backdrop-blur transition-colors hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--fall-accent)] dark:border-white/10 dark:bg-zinc-950/70 dark:text-zinc-400 dark:hover:text-white\"\n        >\n          <span aria-hidden=\"true\">{active ? \"↺\" : \"↓\"}</span>\n          {active ? \"Reset\" : \"Drop\"}\n        </button>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/falling-text"
}
