{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A GravityLetters component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nexport type GravityLettersTrigger = \"click\" | \"hover\" | \"inView\" | \"manual\";\n\nexport interface GravityLettersProps {\n  bounce?: number;\n  className?: string;\n  collapsed?: boolean;\n  friction?: number;\n  gravity?: number;\n  onCollapsedChange?: (collapsed: boolean) => void;\n  spread?: number;\n  stagger?: number;\n  text: string;\n  trigger?: GravityLettersTrigger;\n}\n\ninterface LetterOrigin {\n  height: number;\n  width: number;\n  x: number;\n  y: number;\n}\n\ninterface LetterPhysics {\n  angle: number;\n  angularVel: number;\n  dx: number;\n  dy: number;\n  releaseAt: number;\n  settled: boolean;\n  vx: number;\n  vy: number;\n}\n\ntype Phase = \"falling\" | \"idle\" | \"reforming\" | \"settled\";\n\nconst DEFAULT_GRAVITY = 1200;\nconst DEFAULT_BOUNCE = 0.35;\nconst DEFAULT_FRICTION = 0.4;\nconst DEFAULT_SPREAD = 140;\nconst DEFAULT_STAGGER = 40;\nconst MAX_DT_SECONDS = 0.05;\nconst REST_VELOCITY = 20;\nconst REST_HORIZONTAL_VELOCITY = 5;\nconst SPRING_STIFFNESS = 180;\nconst SPRING_DAMPING = 18;\nconst REFORM_EPSILON_POSITION = 0.5;\nconst REFORM_EPSILON_ANGLE = 0.5;\nconst REFORM_EPSILON_VELOCITY = 5;\n\nconst GravityLetters = ({\n  bounce = DEFAULT_BOUNCE,\n  className,\n  collapsed: collapsedProp,\n  friction = DEFAULT_FRICTION,\n  gravity = DEFAULT_GRAVITY,\n  onCollapsedChange,\n  spread = DEFAULT_SPREAD,\n  stagger = DEFAULT_STAGGER,\n  text,\n  trigger = \"hover\",\n}: GravityLettersProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const letters = useMemo(() => Array.from(text), [text]);\n\n  const isControlled = collapsedProp !== undefined;\n  const [internalCollapsed, setInternalCollapsed] = useState(false);\n  const collapsed = isControlled ? Boolean(collapsedProp) : internalCollapsed;\n\n  const onCollapsedChangeRef = useRef(onCollapsedChange);\n  useEffect(() => {\n    onCollapsedChangeRef.current = onCollapsedChange;\n  });\n\n  const setCollapsed = useCallback(\n    (next: boolean) => {\n      if (!isControlled) {\n        setInternalCollapsed(next);\n      }\n      onCollapsedChangeRef.current?.(next);\n    },\n    [isControlled]\n  );\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const measureRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const letterRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const originsRef = useRef<LetterOrigin[]>([]);\n  const physicsRef = useRef<LetterPhysics[]>([]);\n  const phaseRef = useRef<Phase>(\"idle\");\n  const rafRef = useRef<number | null>(null);\n  const lastTickRef = useRef<number | null>(null);\n  const configRef = useRef({ bounce, friction, gravity, spread, stagger });\n\n  useEffect(() => {\n    configRef.current = { bounce, friction, gravity, spread, stagger };\n  }, [bounce, friction, gravity, spread, stagger]);\n\n  const applyIdleTransforms = useCallback(() => {\n    for (let i = 0; i < letterRefs.current.length; i++) {\n      const el = letterRefs.current[i];\n      const origin = originsRef.current[i];\n      if (el && origin) {\n        el.style.transform = `translate3d(${origin.x}px, ${origin.y}px, 0) rotate(0deg)`;\n      }\n    }\n  }, []);\n\n  useLayoutEffect(() => {\n    if (shouldReduceMotion) {\n      return;\n    }\n\n    const container = containerRef.current;\n    if (!container) {\n      return;\n    }\n\n    const measure = () => {\n      const containerRect = container.getBoundingClientRect();\n      originsRef.current = measureRefs.current.map((el) => {\n        if (!el) {\n          return { height: 0, width: 0, x: 0, y: 0 };\n        }\n        const rect = el.getBoundingClientRect();\n        return {\n          height: rect.height,\n          width: rect.width,\n          x: rect.left - containerRect.left,\n          y: rect.top - containerRect.top,\n        };\n      });\n\n      if (phaseRef.current === \"idle\") {\n        applyIdleTransforms();\n      }\n    };\n\n    measure();\n\n    const resizeObserver = new ResizeObserver(() => measure());\n    resizeObserver.observe(container);\n\n    return () => resizeObserver.disconnect();\n  }, [letters, shouldReduceMotion, applyIdleTransforms]);\n\n  const startLoop = useCallback(() => {\n    if (rafRef.current !== null) {\n      return;\n    }\n\n    lastTickRef.current = null;\n\n    const tick = (now: number) => {\n      const lastTick = lastTickRef.current ?? now;\n      const dt = Math.min((now - lastTick) / 1000, MAX_DT_SECONDS);\n      lastTickRef.current = now;\n\n      const container = containerRef.current;\n      if (!container) {\n        rafRef.current = null;\n        return;\n      }\n\n      const rect = container.getBoundingClientRect();\n      const floorY = rect.height;\n      const wallRight = rect.width;\n      const { bounce: b, friction: f, gravity: g } = configRef.current;\n\n      let stillActive = false;\n\n      for (let i = 0; i < physicsRef.current.length; i++) {\n        const p = physicsRef.current[i];\n        const el = letterRefs.current[i];\n        const origin = originsRef.current[i];\n        if (!(p && el && origin)) {\n          continue;\n        }\n\n        if (phaseRef.current === \"falling\") {\n          if (now < p.releaseAt) {\n            stillActive = true;\n            continue;\n          }\n          if (p.settled) {\n            continue;\n          }\n          stillActive = true;\n\n          p.vy += g * dt;\n          p.dx += p.vx * dt;\n          p.dy += p.vy * dt;\n          p.angle += p.angularVel * dt;\n\n          const floorLimit = floorY - origin.y - origin.height;\n          if (p.dy >= floorLimit) {\n            p.dy = floorLimit;\n            p.vy = -p.vy * b;\n            p.vx *= 1 - f;\n            p.angularVel *= 1 - f;\n            if (\n              Math.abs(p.vy) < REST_VELOCITY &&\n              Math.abs(p.vx) < REST_HORIZONTAL_VELOCITY\n            ) {\n              p.vy = 0;\n              p.vx = 0;\n              p.angularVel = 0;\n              p.settled = true;\n            }\n          }\n\n          const leftLimit = -origin.x;\n          const rightLimit = wallRight - origin.x - origin.width;\n          if (p.dx <= leftLimit) {\n            p.dx = leftLimit;\n            p.vx = -p.vx * b;\n          } else if (p.dx >= rightLimit) {\n            p.dx = rightLimit;\n            p.vx = -p.vx * b;\n          }\n        } else if (phaseRef.current === \"reforming\") {\n          if (p.settled) {\n            continue;\n          }\n\n          const ax = -SPRING_STIFFNESS * p.dx - SPRING_DAMPING * p.vx;\n          const ay = -SPRING_STIFFNESS * p.dy - SPRING_DAMPING * p.vy;\n          const aAngle =\n            -SPRING_STIFFNESS * p.angle - SPRING_DAMPING * p.angularVel;\n\n          p.vx += ax * dt;\n          p.vy += ay * dt;\n          p.angularVel += aAngle * dt;\n          p.dx += p.vx * dt;\n          p.dy += p.vy * dt;\n          p.angle += p.angularVel * dt;\n\n          if (\n            Math.abs(p.dx) < REFORM_EPSILON_POSITION &&\n            Math.abs(p.dy) < REFORM_EPSILON_POSITION &&\n            Math.abs(p.angle) < REFORM_EPSILON_ANGLE &&\n            Math.abs(p.vx) < REFORM_EPSILON_VELOCITY &&\n            Math.abs(p.vy) < REFORM_EPSILON_VELOCITY\n          ) {\n            p.dx = 0;\n            p.dy = 0;\n            p.angle = 0;\n            p.vx = 0;\n            p.vy = 0;\n            p.settled = true;\n          } else {\n            stillActive = true;\n          }\n        }\n\n        el.style.transform = `translate3d(${origin.x + p.dx}px, ${\n          origin.y + p.dy\n        }px, 0) rotate(${p.angle}deg)`;\n      }\n\n      if (stillActive) {\n        rafRef.current = requestAnimationFrame(tick);\n      } else {\n        rafRef.current = null;\n        phaseRef.current = phaseRef.current === \"falling\" ? \"settled\" : \"idle\";\n        if (phaseRef.current === \"idle\") {\n          applyIdleTransforms();\n        }\n      }\n    };\n\n    rafRef.current = requestAnimationFrame(tick);\n  }, [applyIdleTransforms]);\n\n  const startFalling = useCallback(() => {\n    if (originsRef.current.length === 0) {\n      return;\n    }\n\n    const now = performance.now();\n    const { spread: s, stagger: st } = configRef.current;\n\n    physicsRef.current = letters.map((_, index) => ({\n      angle: 0,\n      angularVel: (Math.random() * 2 - 1) * 180,\n      dx: 0,\n      dy: 0,\n      releaseAt: now + index * st,\n      settled: false,\n      vx: (Math.random() * 2 - 1) * s,\n      vy: 0,\n    }));\n\n    phaseRef.current = \"falling\";\n    startLoop();\n  }, [letters, startLoop]);\n\n  const startReforming = useCallback(() => {\n    if (physicsRef.current.length === 0) {\n      return;\n    }\n\n    for (const p of physicsRef.current) {\n      p.settled = false;\n    }\n\n    phaseRef.current = \"reforming\";\n    startLoop();\n  }, [startLoop]);\n\n  useEffect(() => {\n    if (shouldReduceMotion) {\n      return;\n    }\n\n    if (collapsed) {\n      startFalling();\n    } else {\n      startReforming();\n    }\n    // biome-ignore lint/correctness/useExhaustiveDependencies: startFalling/startReforming are stable via useCallback and read live refs\n  }, [collapsed, shouldReduceMotion]);\n\n  useEffect(\n    () => () => {\n      if (rafRef.current !== null) {\n        cancelAnimationFrame(rafRef.current);\n      }\n    },\n    []\n  );\n\n  useEffect(() => {\n    if (trigger !== \"inView\" || shouldReduceMotion) {\n      return;\n    }\n\n    const container = containerRef.current;\n    if (!container) {\n      return;\n    }\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          if (entry.isIntersecting) {\n            setCollapsed(true);\n            observer.disconnect();\n          }\n        }\n      },\n      { threshold: 0.4 }\n    );\n\n    observer.observe(container);\n    return () => observer.disconnect();\n  }, [trigger, shouldReduceMotion, setCollapsed]);\n\n  const isInteractive = trigger === \"hover\" || trigger === \"click\";\n\n  const handleMouseEnter = () => {\n    if (trigger === \"hover\") {\n      setCollapsed(true);\n    }\n  };\n  const handleMouseLeave = () => {\n    if (trigger === \"hover\") {\n      setCollapsed(false);\n    }\n  };\n  const handleFocus = () => {\n    if (trigger === \"hover\") {\n      setCollapsed(true);\n    }\n  };\n  const handleBlur = () => {\n    if (trigger === \"hover\") {\n      setCollapsed(false);\n    }\n  };\n  const handleClick = () => {\n    if (trigger === \"click\") {\n      setCollapsed(!collapsed);\n    }\n  };\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (trigger === \"click\" && (event.key === \"Enter\" || event.key === \" \")) {\n      event.preventDefault();\n      setCollapsed(!collapsed);\n    }\n  };\n\n  if (shouldReduceMotion) {\n    return (\n      <div className={cn(\"relative\", className)}>\n        <span>{text}</span>\n      </div>\n    );\n  }\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: hover/click triggers are opt-in via the `trigger` prop and mirrored with keyboard handlers below\n    <div\n      className={cn(\n        \"relative isolate min-h-24 select-none overflow-hidden\",\n        className\n      )}\n      aria-pressed={trigger === \"click\" ? collapsed : undefined}\n      onBlur={isInteractive ? handleBlur : undefined}\n      onClick={isInteractive ? handleClick : undefined}\n      onFocus={isInteractive ? handleFocus : undefined}\n      onKeyDown={isInteractive ? handleKeyDown : undefined}\n      onMouseEnter={isInteractive ? handleMouseEnter : undefined}\n      onMouseLeave={isInteractive ? handleMouseLeave : undefined}\n      ref={containerRef}\n      role={trigger === \"click\" ? \"button\" : undefined}\n      // biome-ignore lint/a11y/noNoninteractiveTabindex: interactive trigger modes need keyboard focus to fall/reform the text\n      tabIndex={isInteractive ? 0 : undefined}\n    >\n      <span className=\"sr-only\">{text}</span>\n\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none invisible flex flex-wrap\"\n      >\n        {letters.map((char, index) => (\n          <span\n            className=\"inline-block whitespace-pre\"\n            // biome-ignore lint/suspicious/noArrayIndexKey: characters have no stable id\n            key={index}\n            ref={(el) => {\n              measureRefs.current[index] = el;\n            }}\n          >\n            {char}\n          </span>\n        ))}\n      </div>\n\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n        {letters.map((char, index) => (\n          <span\n            className=\"absolute top-0 left-0 inline-block whitespace-pre will-change-transform\"\n            // biome-ignore lint/suspicious/noArrayIndexKey: characters have no stable id\n            key={index}\n            ref={(el) => {\n              letterRefs.current[index] = el;\n            }}\n          >\n            {char}\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n};\n\nexport default GravityLetters;\n","path":"index.tsx","target":"components/smoothui/gravity-letters/index.tsx","type":"registry:ui"}],"name":"gravity-letters","registryDependencies":[],"title":"Gravity Letters","type":"registry:ui"}