{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wave-text",
  "type": "registry:ui",
  "title": "Wave Text",
  "description": "Letters that ripple in a springy wave from the letter nearest the pointer — on hover, focus, in view or continuously.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/wave-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, type AnimationPlaybackControls } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport type WaveTextTrigger = \"hover\" | \"view\" | \"continuous\";\n\nexport interface WaveTextProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\" | \"color\"> {\n  /** The text to render. */\n  text?: string;\n  /** Element to render. */\n  as?: \"span\" | \"p\" | \"h1\" | \"h2\" | \"h3\" | \"div\";\n  /**\n   * `hover`: on pointer enter (from the nearest letter), tap, or focus of the closest focusable\n   * ancestor (from the start). `view`: when scrolled into view. `continuous`: keeps rippling while visible.\n   */\n  trigger?: WaveTextTrigger;\n  /** Repeat while hovered (hover) or while visible (view). */\n  loop?: boolean;\n  /** Pause between repeated waves, in ms. */\n  loopDelay?: number;\n  /** Peak lift of each letter, in px. */\n  amplitude?: number;\n  /** Delay between neighbouring letters, in ms. */\n  stagger?: number;\n  /** Spring each letter rides; its damping decides how much it bounces. */\n  spring?: Spring;\n  /** Peak scale of a letter (1 = no scaling). */\n  scale?: number;\n  /** Peak rotation of a letter in degrees (0 = upright). */\n  rotate?: number;\n  /** Colour letters flash as the wave passes. `null` disables the colour flash. */\n  color?: string | null;\n}\n\n/* --------------------------------- helpers --------------------------------- */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/** Splits text into words (kept unbreakable) and whitespace, and words into graphemes. */\nfunction tokenize(text: string) {\n  const segmenter = typeof Intl !== \"undefined\" && \"Segmenter\" in Intl ? new Intl.Segmenter(\"en\", { granularity: \"grapheme\" }) : null;\n  const graphemes = (word: string) => (segmenter ? Array.from(segmenter.segment(word), (s) => s.segment) : Array.from(word));\n  let index = 0;\n  return text.split(/(\\s+)/).map((part) =>\n    /^\\s+$/.test(part) || part === \"\"\n      ? { space: part, letters: [] as { char: string; index: number }[] }\n      : { space: \"\", letters: graphemes(part).map((char) => ({ char, index: index++ })) },\n  );\n}\n\n/**\n * Initial velocity that makes a spring starting at rest position overshoot to exactly `peak`.\n * For an underdamped spring the first peak of x(t) = (v₀/ω_d)·e^(−ζω₀t)·sin(ω_d t) is\n * (v₀/ω₀)·e^(−ζ/√(1−ζ²)·atan(√(1−ζ²)/ζ)), so we invert that.\n */\nfunction kickVelocity(peak: number, { stiffness, damping, mass }: Spring) {\n  if (peak === 0) return 0;\n  const w0 = Math.sqrt(stiffness / mass);\n  const zeta = damping / (2 * Math.sqrt(stiffness * mass));\n  if (zeta >= 1) return peak * w0 * Math.E;\n  const s = Math.sqrt(1 - zeta * zeta);\n  return (peak * w0) / Math.exp((-zeta / s) * Math.atan2(s, zeta));\n}\n\nconst COLOR_MIX = \"color-mix(in oklab, var(--wt-color) calc(var(--wt-mix, 0) * 100%), currentColor)\";\n\n/**\n * Letters that ripple in a wave: each one gets a spring \"kick\" so it lifts by `amplitude` and settles,\n * with a stagger spreading outward from the letter nearest the pointer (or from the first letter).\n * The text is exposed once to assistive tech; the animated letters are aria-hidden.\n */\nexport function WaveText({\n  text = \"Making waves\",\n  as = \"span\",\n  trigger = \"hover\",\n  loop = false,\n  loopDelay = 1200,\n  amplitude = 10,\n  stagger = 40,\n  spring = { stiffness: 320, damping: 11, mass: 0.6 },\n  scale = 1,\n  rotate = 0,\n  color = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: WaveTextProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const visualRef = React.useRef<HTMLSpanElement>(null);\n  const Tag = as as React.ElementType;\n  const tokens = tokenize(text);\n  const { stiffness, damping, mass } = spring;\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const visual = visualRef.current;\n    if (!root || !visual || reduceMotion) return;\n    const letters = Array.from(visual.querySelectorAll<HTMLElement>(\"[data-wt-letter]\"));\n    if (!letters.length) return;\n\n    const physics = { stiffness, damping, mass };\n    const running = new Set<AnimationPlaybackControls>();\n    const track = (controls: AnimationPlaybackControls) => {\n      running.add(controls);\n      void controls.finished.then(() => running.delete(controls)).catch(() => running.delete(controls));\n    };\n\n    const wave = (origin: number) => {\n      letters.forEach((el, i) => {\n        const delay = (Math.abs(i - origin) * stagger) / 1000;\n        const spec = { type: \"spring\" as const, ...physics, delay };\n        // Rest → rest with an initial velocity: a single, physically honest bump.\n        track(\n          animate(\n            el,\n            { y: 0, scale: 1, rotate: 0 },\n            {\n              y: { ...spec, velocity: -kickVelocity(amplitude, physics) },\n              scale: { ...spec, velocity: kickVelocity(scale - 1, physics) },\n              rotate: { ...spec, velocity: kickVelocity(rotate, physics) },\n            },\n          ),\n        );\n        if (color) track(animate(el, { \"--wt-mix\": [1, 0] }, { duration: 0.75, delay, ease: [0.22, 1, 0.36, 1] }));\n      });\n    };\n\n    const nearest = (x: number, y: number) => {\n      let best = 0;\n      let bestDistance = Infinity;\n      letters.forEach((el, i) => {\n        const r = el.getBoundingClientRect();\n        const d = Math.hypot(r.left + r.width / 2 - x, r.top + r.height / 2 - y);\n        if (d < bestDistance) {\n          bestDistance = d;\n          best = i;\n        }\n      });\n      return best;\n    };\n\n    // Repeating waves (loop / continuous). Skipped while the tab is hidden.\n    const period = Math.max(loopDelay, 0) + letters.length * stagger + 500;\n    let timer: ReturnType<typeof setInterval> | undefined;\n    const startLoop = (origin: () => number) => {\n      clearInterval(timer);\n      timer = setInterval(() => {\n        if (!document.hidden) wave(origin());\n      }, period);\n    };\n    const stopLoop = () => clearInterval(timer);\n\n    const cleanups: (() => void)[] = [];\n\n    if (trigger === \"hover\") {\n      // Hover the closest interactive ancestor too (e.g. a button or link wrapping the text).\n      const host = root.closest<HTMLElement>(\"a, button, [tabindex], label\") ?? root;\n      let pointer = { x: 0, y: 0 };\n      const onEnter = (event: PointerEvent) => {\n        if (event.pointerType !== \"mouse\") return;\n        pointer = { x: event.clientX, y: event.clientY };\n        wave(nearest(pointer.x, pointer.y));\n        if (loop) startLoop(() => nearest(pointer.x, pointer.y));\n      };\n      const onMove = (event: PointerEvent) => {\n        pointer = { x: event.clientX, y: event.clientY };\n      };\n      const onDown = (event: PointerEvent) => {\n        if (event.pointerType !== \"mouse\") wave(nearest(event.clientX, event.clientY));\n      };\n      const onFocus = () => wave(0);\n      host.addEventListener(\"pointerenter\", onEnter);\n      host.addEventListener(\"pointermove\", onMove);\n      host.addEventListener(\"pointerleave\", stopLoop);\n      host.addEventListener(\"pointerdown\", onDown);\n      host.addEventListener(\"focusin\", onFocus);\n      cleanups.push(() => {\n        host.removeEventListener(\"pointerenter\", onEnter);\n        host.removeEventListener(\"pointermove\", onMove);\n        host.removeEventListener(\"pointerleave\", stopLoop);\n        host.removeEventListener(\"pointerdown\", onDown);\n        host.removeEventListener(\"focusin\", onFocus);\n      });\n    } else {\n      const repeat = trigger === \"continuous\" || loop;\n      const observer = new IntersectionObserver(\n        ([entry]) => {\n          if (entry?.isIntersecting) {\n            wave(0);\n            if (repeat) startLoop(() => 0);\n            else observer.disconnect();\n          } else {\n            stopLoop();\n          }\n        },\n        { threshold: 0.5 },\n      );\n      observer.observe(root);\n      cleanups.push(() => observer.disconnect());\n    }\n\n    return () => {\n      stopLoop();\n      cleanups.forEach((fn) => fn());\n      running.forEach((controls) => controls.stop());\n      // Leave letters at rest if we were interrupted mid-wave.\n      letters.forEach((el) => {\n        el.style.transform = \"\";\n        el.style.removeProperty(\"--wt-mix\");\n      });\n    };\n  }, [text, trigger, loop, loopDelay, amplitude, stagger, stiffness, damping, mass, scale, rotate, color, reduceMotion]);\n\n  return (\n    <Tag\n      ref={rootRef}\n      className={className}\n      style={{ ...(color ? { [\"--wt-color\" as string]: color } : null), ...style }}\n      {...props}\n    >\n      <span className=\"sr-only\">{text}</span>\n      <span ref={visualRef} aria-hidden=\"true\">\n        {tokens.map((token, i) =>\n          token.letters.length === 0 ? (\n            token.space\n          ) : (\n            // Words stay unbreakable; wrapping happens only at the original whitespace.\n            <span key={i} className=\"inline-block whitespace-nowrap\">\n              {token.letters.map(({ char, index }) => (\n                <span key={index} data-wt-letter=\"\" className=\"inline-block origin-bottom\" style={color ? { color: COLOR_MIX } : undefined}>\n                  {char}\n                </span>\n              ))}\n            </span>\n          ),\n        )}\n      </span>\n    </Tag>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/wave-text"
}
