{
  "name": "falling-text",
  "dependencies": [
    "matter-js"
  ],
  "devDependencies": [
    "@types/matter-js"
  ],
  "files": [
    {
      "path": "ui/falling-text.tsx",
      "content": "\"use client\";\nimport Matter from \"matter-js\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport interface FallingTextProps {\n  /** The text content to display and animate */\n  text: string;\n  /** Words to highlight with special styling */\n  highlightWords?: string[];\n  /** When to trigger the falling animation */\n  trigger?: \"auto\" | \"scroll\" | \"click\" | \"hover\";\n  /** Background color for the physics canvas */\n  backgroundColor?: string;\n  /** Show physics wireframes for debugging */\n  wireframes?: boolean;\n  /** Gravity strength (default: 1) */\n  gravity?: number;\n  /** Mouse interaction stiffness (0-1, default: 0.2) */\n  mouseConstraintStiffness?: number;\n  /** Font size for the text */\n  fontSize?: string;\n  /** Custom className for the container */\n  className?: string;\n  /** Callback when animation starts */\n  onAnimationStart?: () => void;\n  /** Callback when animation ends (all bodies settled) */\n  onAnimationEnd?: () => void;\n  /** Physics properties for word bodies */\n  physicsOptions?: {\n    restitution?: number; // Bounciness (0-1)\n    frictionAir?: number; // Air resistance\n    friction?: number; // Surface friction\n    density?: number; // Mass density\n  };\n  /** Initial velocity range for words */\n  initialVelocity?: {\n    x?: number; // Horizontal velocity range\n    y?: number; // Vertical velocity range\n    angular?: number; // Angular velocity range\n  };\n  /** Custom highlight styles */\n  highlightClassName?: string;\n  /** Word spacing in pixels */\n  wordSpacing?: number;\n  /** Minimum container height */\n  minHeight?: string;\n  /** Enable/disable mouse interactions */\n  enableMouseInteraction?: boolean;\n  /** Reset trigger - increment to reset animation */\n  resetKey?: number;\n}\n\nconst FallingText: React.FC<FallingTextProps> = ({\n  text,\n  highlightWords = [],\n  trigger = \"auto\",\n  backgroundColor = \"transparent\",\n  wireframes = false,\n  gravity = 1,\n  mouseConstraintStiffness = 0.2,\n  fontSize = \"1rem\",\n  className = \"\",\n  onAnimationStart,\n  onAnimationEnd,\n  physicsOptions = {},\n  initialVelocity = {},\n  highlightClassName = \"text-cyan-500 font-bold\",\n  wordSpacing = 2,\n  minHeight = \"300px\",\n  enableMouseInteraction = true,\n  resetKey = 0,\n}) => {\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  const textRef = useRef<HTMLDivElement | null>(null);\n  const canvasContainerRef = useRef<HTMLDivElement | null>(null);\n  const engineRef = useRef<Matter.Engine | null>(null);\n  const renderRef = useRef<Matter.Render | null>(null);\n  const runnerRef = useRef<Matter.Runner | null>(null);\n  const animationFrameRef = useRef<number | null>(null);\n  const hasStartedRef = useRef(false);\n\n  const [effectStarted, setEffectStarted] = useState(false);\n  const [isReady, setIsReady] = useState(false);\n\n  // Merge default physics options with user-provided ones\n  const mergedPhysicsOptions = {\n    restitution: 0.8,\n    frictionAir: 0.01,\n    friction: 0.2,\n    density: 0.001,\n    ...physicsOptions,\n  };\n\n  const mergedInitialVelocity = {\n    x: 5,\n    y: 0,\n    angular: 0.05,\n    ...initialVelocity,\n  };\n\n  // Cleanup function\n  const cleanup = useCallback(() => {\n    if (animationFrameRef.current) {\n      cancelAnimationFrame(animationFrameRef.current);\n      animationFrameRef.current = null;\n    }\n\n    if (runnerRef.current && engineRef.current) {\n      Matter.Runner.stop(runnerRef.current);\n      runnerRef.current = null;\n    }\n\n    if (renderRef.current) {\n      Matter.Render.stop(renderRef.current);\n      if (renderRef.current.canvas && canvasContainerRef.current) {\n        try {\n          canvasContainerRef.current.removeChild(renderRef.current.canvas);\n        } catch (_e) {\n          // Canvas might already be removed\n        }\n      }\n      renderRef.current = null;\n    }\n\n    if (engineRef.current) {\n      Matter.World.clear(engineRef.current.world, false);\n      Matter.Engine.clear(engineRef.current);\n      engineRef.current = null;\n    }\n\n    hasStartedRef.current = false;\n  }, []);\n\n  // Reset animation when resetKey changes\n  useEffect(() => {\n    if (resetKey > 0) {\n      cleanup();\n      setEffectStarted(false);\n      setIsReady(false);\n      // Trigger re-initialization\n      setTimeout(() => setIsReady(true), 50);\n    }\n  }, [resetKey, cleanup]);\n\n  // Initialize text spans\n  useEffect(() => {\n    if (!textRef.current || !text) return;\n\n    const words = text.split(\" \").filter((word) => word.length > 0);\n\n    const newHTML = words\n      .map((word) => {\n        const isHighlighted = highlightWords.some((hw) =>\n          word.toLowerCase().startsWith(hw.toLowerCase()),\n        );\n        return `<span\n          class=\"inline-block select-none transition-colors duration-200 ${\n            isHighlighted ? highlightClassName : \"\"\n          }\"\n          style=\"margin: 0 ${wordSpacing}px;\"\n        >\n          ${word}\n        </span>`;\n      })\n      .join(\" \");\n\n    textRef.current.innerHTML = newHTML;\n    setIsReady(true);\n  }, [text, highlightWords, highlightClassName, wordSpacing]);\n\n  // Handle trigger mechanisms\n  useEffect(() => {\n    if (trigger === \"auto\") {\n      setEffectStarted(true);\n      return;\n    }\n\n    if (trigger === \"scroll\" && containerRef.current) {\n      const observer = new IntersectionObserver(\n        ([entry]) => {\n          if (entry?.isIntersecting) {\n            setEffectStarted(true);\n            observer.disconnect();\n          }\n        },\n        { threshold: 0.1 },\n      );\n      observer.observe(containerRef.current);\n      return () => observer.disconnect();\n    }\n  }, [trigger]);\n\n  // Main physics effect\n  useEffect(() => {\n    if (!effectStarted || !isReady || hasStartedRef.current) return;\n\n    const {\n      Engine,\n      Render,\n      World,\n      Bodies,\n      Runner,\n      Mouse,\n      MouseConstraint,\n      Body,\n    } = Matter;\n\n    if (\n      !containerRef.current ||\n      !canvasContainerRef.current ||\n      !textRef.current\n    )\n      return;\n\n    const containerRect = containerRef.current.getBoundingClientRect();\n    const width = containerRect.width;\n    const height = containerRect.height;\n\n    if (width <= 0 || height <= 0) {\n      console.warn(\"FallingText: Container has invalid dimensions\");\n      return;\n    }\n\n    hasStartedRef.current = true;\n    onAnimationStart?.();\n\n    // Create engine\n    const engine = Engine.create();\n    engine.world.gravity.y = gravity;\n    engineRef.current = engine;\n\n    // Create renderer\n    const render = Render.create({\n      element: canvasContainerRef.current,\n      engine,\n      options: {\n        width,\n        height,\n        background: backgroundColor,\n        wireframes,\n      },\n    });\n    renderRef.current = render;\n\n    // Create boundaries\n    const boundaryOptions = {\n      isStatic: true,\n      render: { fillStyle: \"transparent\" },\n    };\n\n    const floor = Bodies.rectangle(\n      width / 2,\n      height + 25,\n      width,\n      50,\n      boundaryOptions,\n    );\n    const leftWall = Bodies.rectangle(\n      -25,\n      height / 2,\n      50,\n      height,\n      boundaryOptions,\n    );\n    const rightWall = Bodies.rectangle(\n      width + 25,\n      height / 2,\n      50,\n      height,\n      boundaryOptions,\n    );\n    const ceiling = Bodies.rectangle(\n      width / 2,\n      -25,\n      width,\n      50,\n      boundaryOptions,\n    );\n\n    // Create word bodies\n    const wordSpans = textRef.current.querySelectorAll(\"span\");\n    const wordBodies = Array.from(wordSpans).map((elem) => {\n      const rect = elem.getBoundingClientRect();\n      const x = rect.left - containerRect.left + rect.width / 2;\n      const y = rect.top - containerRect.top + rect.height / 2;\n\n      const body = Bodies.rectangle(x, y, rect.width, rect.height, {\n        render: { fillStyle: \"transparent\" },\n        ...mergedPhysicsOptions,\n      });\n\n      // Set initial velocities\n      Body.setVelocity(body, {\n        x: (Math.random() - 0.5) * mergedInitialVelocity.x,\n        y: (Math.random() - 0.5) * mergedInitialVelocity.y,\n      });\n      Body.setAngularVelocity(\n        body,\n        (Math.random() - 0.5) * mergedInitialVelocity.angular,\n      );\n\n      return { elem: elem as HTMLElement, body };\n    });\n\n    // Position elements initially\n    wordBodies.forEach(({ elem, body }) => {\n      elem.style.position = \"absolute\";\n      elem.style.left = `${body.position.x}px`;\n      elem.style.top = `${body.position.y}px`;\n      elem.style.transform = \"translate(-50%, -50%)\";\n      elem.style.willChange = \"transform\";\n    });\n\n    // Add mouse interaction\n    let mouseConstraint: Matter.MouseConstraint | null = null;\n    if (enableMouseInteraction) {\n      const mouse = Mouse.create(containerRef.current);\n      mouseConstraint = MouseConstraint.create(engine, {\n        mouse,\n        constraint: {\n          stiffness: mouseConstraintStiffness,\n          render: { visible: false },\n        },\n      });\n      render.mouse = mouse;\n    }\n\n    // Add all bodies to world\n    const bodiesToAdd = [\n      floor,\n      leftWall,\n      rightWall,\n      ceiling,\n      ...wordBodies.map((wb) => wb.body),\n    ];\n    if (mouseConstraint) {\n      bodiesToAdd.push(mouseConstraint as any);\n    }\n    World.add(engine.world, bodiesToAdd);\n\n    // Create runner\n    const runner = Runner.create();\n    runnerRef.current = runner;\n    Runner.run(runner, engine);\n    Render.run(render);\n\n    // Update loop\n    let settledCount = 0;\n    const updateLoop = () => {\n      wordBodies.forEach(({ body, elem }) => {\n        const { x, y } = body.position;\n        elem.style.left = `${x}px`;\n        elem.style.top = `${y}px`;\n        elem.style.transform = `translate(-50%, -50%) rotate(${body.angle}rad)`;\n      });\n\n      // Check if bodies have settled\n      const allSettled = wordBodies.every(({ body }) => {\n        const speed = Math.abs(body.velocity.x) + Math.abs(body.velocity.y);\n        return speed < 0.1;\n      });\n\n      if (allSettled) {\n        settledCount++;\n        if (settledCount > 60) {\n          // Settled for 1 second at 60fps\n          onAnimationEnd?.();\n          return;\n        }\n      } else {\n        settledCount = 0;\n      }\n\n      animationFrameRef.current = requestAnimationFrame(updateLoop);\n    };\n    updateLoop();\n\n    return cleanup;\n  }, [\n    effectStarted,\n    isReady,\n    gravity,\n    wireframes,\n    backgroundColor,\n    mouseConstraintStiffness,\n    mergedPhysicsOptions,\n    mergedInitialVelocity,\n    enableMouseInteraction,\n    cleanup,\n    onAnimationStart,\n    onAnimationEnd,\n  ]);\n\n  const handleTrigger = useCallback(() => {\n    if (!effectStarted && (trigger === \"click\" || trigger === \"hover\")) {\n      setEffectStarted(true);\n    }\n  }, [effectStarted, trigger]);\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: This is a decorative visual effect that responds to mouse events for aesthetic purposes\n    <div\n      ref={containerRef}\n      className={`relative z-[1] w-full overflow-hidden pt-8 text-center ${className}`}\n      style={{ minHeight }}\n      onClick={trigger === \"click\" ? handleTrigger : undefined}\n      onMouseEnter={trigger === \"hover\" ? handleTrigger : undefined}\n      role=\"presentation\"\n      aria-label={\n        trigger !== \"auto\" ? \"Click or hover to animate text\" : undefined\n      }\n    >\n      <div\n        ref={textRef}\n        className=\"pointer-events-none inline-block\"\n        style={{\n          fontSize,\n          lineHeight: 1.4,\n        }}\n        aria-live=\"polite\"\n      />\n\n      <div\n        className=\"pointer-events-none absolute inset-0\"\n        ref={canvasContainerRef}\n        aria-hidden=\"true\"\n      />\n    </div>\n  );\n};\n\nexport default FallingText;\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}