{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"wave-text","type":"registry:component","title":"Wave text","description":"Letters rise in a wave that follows your cursor.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/wave-text.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// Enough lift to read as a wave at 44px without letters touching the line above.\nconst LIFT = 8;\nconst GROW = 0.08;\n// About two letters either side of the cursor get pulled along.\nconst SPREAD = 44;\n// Soft enough that the crest trails the cursor slightly; no overshoot, so\n// letters never dip below the baseline.\nconst FOLLOW = { stiffness: 220, damping: 26 };\nconst SETTLE = { stiffness: 180, damping: 24 };\n// A tap ripple has to cross the whole word, so it runs longer than UI motion.\nconst RIPPLE_S = 0.9;\n// How far letters away from the cursor fade toward muted in reduced motion.\nconst DIM = 45;\n\nfunction bell(distance: number) {\n  return Math.exp(-((distance / SPREAD) ** 2));\n}\n\ntype Field = {\n  pointer: MotionValue<number>;\n  hover: MotionValue<number>;\n  ripple: MotionValue<number>;\n  // [tap origin x, heading width], written on tap, read by every letter.\n  tap: React.RefObject<[number, number]>;\n  reduce: boolean;\n};\n\nexport function WaveText({\n  text,\n  className,\n}: {\n  text: string;\n  className?: string;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  const headingRef = useRef<HTMLHeadingElement>(null);\n\n  const rawPointer = useMotionValue(0);\n  const pointer = useSpring(rawPointer, FOLLOW);\n  const rawHover = useMotionValue(0);\n  const hover = useSpring(rawHover, SETTLE);\n  // 0 to 1 as a tap ripple travels outward; 1 means spent.\n  const ripple = useMotionValue(1);\n  const tap = useRef<[number, number]>([0, 1]);\n\n  const field: Field = { pointer, hover, ripple, tap, reduce };\n\n  const localX = (e: React.PointerEvent) =>\n    e.clientX - (headingRef.current?.getBoundingClientRect().left ?? 0);\n\n  const words = text.split(\" \");\n  const starts = words.map((_, i) =>\n    words.slice(0, i).reduce((sum, word) => sum + word.length, 0),\n  );\n\n  return (\n    <h2\n      ref={headingRef}\n      aria-label={text}\n      className={cn(\n        \"relative cursor-default text-[44px] leading-tight font-medium tracking-tight text-foreground select-none\",\n        className,\n      )}\n      onPointerEnter={(e) => {\n        if (e.pointerType === \"touch\") return;\n        // Start the crest under the cursor instead of sweeping in from the edge.\n        const x = localX(e);\n        rawPointer.jump(x);\n        pointer.jump(x);\n      }}\n      onPointerMove={(e) => {\n        if (e.pointerType === \"touch\") return;\n        rawPointer.set(localX(e));\n        rawHover.set(1);\n      }}\n      onPointerLeave={() => rawHover.set(0)}\n      onPointerDown={(e) => {\n        if (e.pointerType !== \"touch\") return;\n        tap.current = [localX(e), headingRef.current?.offsetWidth ?? 1];\n        animate(ripple, [0, 1], { duration: RIPPLE_S, ease: [0.23, 1, 0.32, 1] });\n      }}\n    >\n      {words.map((word, w) => (\n        <span key={w} aria-hidden>\n          {/* Keeps a word's letters together, so lines only break between words. */}\n          <span className=\"inline-block whitespace-nowrap\">\n            {Array.from(word).map((char, c) => (\n              <Letter\n                key={c}\n                char={char}\n                heading={headingRef}\n                field={field}\n                order={starts[w] + c}\n              />\n            ))}\n          </span>\n          {w < words.length - 1 && \" \"}\n        </span>\n      ))}\n    </h2>\n  );\n}\n\nfunction Letter({\n  char,\n  heading,\n  field,\n  order,\n}: {\n  char: string;\n  heading: React.RefObject<HTMLHeadingElement | null>;\n  field: Field;\n  order: number;\n}) {\n  const ref = useRef<HTMLSpanElement>(null);\n  // Measured on mount and resize, never per frame.\n  const center = useRef(0);\n\n  useEffect(() => {\n    const el = ref.current;\n    const parent = heading.current;\n    if (!el || !parent) return;\n    const measure = () => {\n      // The heading is the offsetParent, and offsetLeft ignores transforms,\n      // so a lifted letter still measures its resting position.\n      center.current = el.offsetLeft + el.offsetWidth / 2;\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(parent);\n    return () => observer.disconnect();\n  }, [heading, order]);\n\n  const { pointer, hover, ripple, tap, reduce } = field;\n\n  const strength = useTransform([pointer, hover, ripple], ([p, h, r]) => {\n    const near = bell((p as number) - center.current) * (h as number);\n    const progress = r as number;\n    if (progress >= 1) return near;\n    // The ring's radius grows with progress while its height fades out.\n    const [from, width] = tap.current;\n    const wave =\n      bell(Math.abs(center.current - from) - progress * width) * (1 - progress);\n    return Math.max(near, wave);\n  });\n\n  // Both styles stay bound in either mode; only what they output changes.\n  const transform = useTransform(strength, (s) =>\n    reduce ? \"none\" : `translateY(${-LIFT * s}px) scale(${1 + GROW * s})`,\n  );\n  const color = useTransform([strength, hover], ([s, h]) =>\n    reduce\n      ? `color-mix(in oklab, var(--foreground), var(--muted-foreground) ${Math.round(\n          DIM * Math.max(0, (h as number) - (s as number)),\n        )}%)`\n      : \"var(--foreground)\",\n  );\n\n  return (\n    <motion.span\n      ref={ref}\n      className=\"inline-block origin-bottom\"\n      style={{ transform, color }}\n    >\n      {char}\n    </motion.span>\n  );\n}\n\nexport default function WaveTextDemo() {\n  return (\n    <div className=\"flex max-w-full flex-col items-center gap-3 text-center\">\n      <WaveText text=\"Ride the wave\" />\n      <p className=\"text-sm text-muted-foreground\">Move across the letters, or tap them.</p>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"categories":["text"],"docs":"From ui lab: https://lab.xevrion.dev/lab/wave-text"}