{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "proximity-text",
  "type": "registry:ui",
  "title": "Proximity Text",
  "description": "Letters swell in weight, width and scale as the pointer approaches — on any variable font.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/proximity-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type ProximityFalloff = \"linear\" | \"smooth\" | \"gaussian\";\n\nexport interface ProximityTextProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** The text to render. */\n  text?: string;\n  /** Element to render. */\n  as?: \"span\" | \"p\" | \"h1\" | \"h2\" | \"h3\" | \"div\";\n  /** Distance in px at which letters stop responding. */\n  radius?: number;\n  /** Weight of letters outside the radius (`wght` axis). */\n  minWeight?: number;\n  /** Weight of the letter directly under the pointer. */\n  maxWeight?: number;\n  /** Shape of the response curve between the pointer and the radius. */\n  falloff?: ProximityFalloff;\n  /** Scale of the letter under the pointer (1 = no scaling). */\n  scale?: number;\n  /** `wdth` axis value under the pointer (100 = normal; needs a font with a width axis). */\n  stretch?: number;\n  /** Skew of the letter under the pointer, in degrees (0 = upright). */\n  slant?: number;\n  /** Blend nearby letters towards `accent`. */\n  colorize?: boolean;\n  /** Colour used when `colorize` is on (any CSS colour). */\n  accent?: string;\n  /** On touch / no-hover devices: a slow idle wave, or static text. */\n  touchBehavior?: \"wave\" | \"static\";\n}\n\n/* ------------------------------ media queries ------------------------------ */\n\nfunction subscribeMedia(query: string) {\n  return (onChange: () => void) => {\n    const mq = window.matchMedia(query);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  };\n}\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\";\nconst NO_HOVER_QUERY = \"(hover: none)\";\nconst subscribeReduced = subscribeMedia(REDUCED_QUERY);\nconst subscribeNoHover = subscribeMedia(NO_HOVER_QUERY);\n\nfunction useMediaQuery(query: string, subscribe: (cb: () => void) => () => void) {\n  return React.useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n\n/* --------------------------------- helpers --------------------------------- */\n\nconst FALLOFF: Record<ProximityFalloff, (d: number) => number> = {\n  linear: (d) => 1 - d,\n  smooth: (d) => 1 - d * d * (3 - 2 * d),\n  gaussian: (d) => Math.exp(-d * d * 4.5),\n};\n\nfunction letterStyle(\n  t: number,\n  { minWeight, maxWeight, stretch, scale, slant }: { minWeight: number; maxWeight: number; stretch: number; scale: number; slant: number },\n) {\n  const weight = minWeight + (maxWeight - minWeight) * t;\n  const axes = [`\"wght\" ${weight.toFixed(1)}`];\n  if (stretch !== 100) axes.push(`\"wdth\" ${(100 + (stretch - 100) * t).toFixed(1)}`);\n  const s = 1 + (scale - 1) * t;\n  const k = slant * t;\n  return {\n    fontVariationSettings: axes.join(\", \"),\n    transform: s !== 1 || k !== 0 ? `scale(${s.toFixed(3)}) skewX(${(-k).toFixed(2)}deg)` : \"\",\n  };\n}\n\n/**\n * Letters that swell towards the pointer. Each frame reads every letter's position, computes a\n * falloff from the pointer and writes `font-variation-settings` (plus optional transform / colour)\n * straight onto the letter — no React render per frame.\n */\nexport function ProximityText({\n  text = \"Type that leans in\",\n  as = \"span\",\n  radius = 140,\n  minWeight = 300,\n  maxWeight = 800,\n  falloff = \"smooth\",\n  scale = 1,\n  stretch = 100,\n  slant = 0,\n  colorize = false,\n  accent = \"#FF7448\",\n  touchBehavior = \"wave\",\n  className = \"\",\n  style,\n  ...props\n}: ProximityTextProps) {\n  const reduceMotion = useMediaQuery(REDUCED_QUERY, subscribeReduced);\n  const noHover = useMediaQuery(NO_HOVER_QUERY, subscribeNoHover);\n  const rootRef = React.useRef<HTMLElement>(null);\n  const visualRef = React.useRef<HTMLSpanElement>(null);\n  const Tag = as as React.ElementType;\n\n  const mode: \"pointer\" | \"wave\" | \"static\" = reduceMotion\n    ? \"static\"\n    : noHover\n      ? touchBehavior === \"wave\"\n        ? \"wave\"\n        : \"static\"\n      : \"pointer\";\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const visual = visualRef.current;\n    if (!root || !visual) return;\n    const letters = Array.from(visual.querySelectorAll<HTMLElement>(\"[data-pt-letter]\"));\n    const options = { minWeight, maxWeight, stretch, scale, slant };\n    const curve = FALLOFF[falloff] ?? FALLOFF.smooth;\n    const current = new Float32Array(letters.length);\n\n    const write = (i: number, t: number) => {\n      const el = letters[i];\n      const { fontVariationSettings, transform } = letterStyle(t, options);\n      el.style.fontVariationSettings = fontVariationSettings;\n      el.style.transform = transform;\n      el.style.color = colorize && t > 0.01 ? `color-mix(in oklab, ${accent} ${(t * 100).toFixed(1)}%, currentColor)` : \"\";\n    };\n\n    // Rest state (also the whole effect for reduced motion / static touch).\n    letters.forEach((_, i) => write(i, 0));\n    if (mode === \"static\" || !letters.length) return;\n\n    let frame = 0;\n    let inView = false;\n    let pointer: { x: number; y: number } | null = null;\n    const startTime = performance.now();\n\n    const tick = (now: number) => {\n      frame = 0;\n      // Batch every read before any write so each frame costs a single layout.\n      const centres = letters.map((el) => {\n        const r = el.getBoundingClientRect();\n        return [r.left + r.width / 2, r.top + r.height / 2] as const;\n      });\n      let moving = false;\n      for (let i = 0; i < letters.length; i++) {\n        let target = 0;\n        if (mode === \"wave\") {\n          // A slow travelling wave across the line for devices without hover.\n          const phase = (now - startTime) / 1400 - i * 0.35;\n          target = Math.pow((Math.sin(phase) + 1) / 2, 3) * 0.7;\n        } else if (pointer) {\n          const d = Math.hypot(centres[i][0] - pointer.x, centres[i][1] - pointer.y) / Math.max(1, radius);\n          target = d >= 1 ? 0 : Math.max(0, Math.min(1, curve(d)));\n        }\n        // Ease towards the target so letters settle rather than snap.\n        const next = mode === \"wave\" ? target : current[i] + (target - current[i]) * 0.22;\n        if (Math.abs(next - current[i]) > 0.002) {\n          moving = true;\n          current[i] = next;\n          write(i, next);\n        } else if (current[i] !== target) {\n          current[i] = target;\n          write(i, target);\n        }\n      }\n      if (moving || mode === \"wave\") request();\n    };\n\n    const request = () => {\n      if (!frame && inView && !document.hidden) frame = requestAnimationFrame(tick);\n    };\n    const stop = () => {\n      cancelAnimationFrame(frame);\n      frame = 0;\n    };\n\n    const onPointerMove = (event: PointerEvent) => {\n      if (event.pointerType !== \"mouse\" && event.pointerType !== \"pen\") return;\n      if (!inView) return;\n      // Cheap bounds check first: ignore the pointer while it is far from the text and letters rest.\n      const box = root.getBoundingClientRect();\n      const near =\n        event.clientX > box.left - radius &&\n        event.clientX < box.right + radius &&\n        event.clientY > box.top - radius &&\n        event.clientY < box.bottom + radius;\n      if (!near) {\n        if (pointer) {\n          pointer = null;\n          request();\n        }\n        return;\n      }\n      pointer = { x: event.clientX, y: event.clientY };\n      request();\n    };\n    const onPointerOut = (event: PointerEvent) => {\n      // Leaving the window: relax every letter.\n      if (event.relatedTarget === null) {\n        pointer = null;\n        request();\n      }\n    };\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        inView = entries.some((entry) => entry.isIntersecting);\n        if (inView) request();\n        else stop();\n      },\n      { rootMargin: `${radius}px` },\n    );\n    observer.observe(root);\n\n    const onVisibility = () => (document.hidden ? stop() : request());\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    if (mode === \"pointer\") {\n      window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n      document.addEventListener(\"pointerout\", onPointerOut);\n    }\n\n    return () => {\n      stop();\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      document.removeEventListener(\"pointerout\", onPointerOut);\n    };\n  }, [text, mode, radius, minWeight, maxWeight, falloff, scale, stretch, slant, colorize, accent]);\n\n  // Keep whitespace as real text so lines can still wrap between words.\n  const tokens = text.split(/(\\s+)/).filter(Boolean);\n  const rest = letterStyle(0, { minWeight, maxWeight, stretch, scale, slant });\n\n  return (\n    <Tag ref={rootRef} className={className} style={style} {...props}>\n      <span className=\"sr-only\">{text}</span>\n      <span ref={visualRef} aria-hidden=\"true\">\n        {tokens.map((token, t) =>\n          /^\\s+$/.test(token) ? (\n            \" \"\n          ) : (\n            <span key={t} className=\"inline-block whitespace-nowrap\">\n              {Array.from(token).map((char, c) => (\n                <span\n                  key={c}\n                  data-pt-letter=\"\"\n                  className=\"inline-block origin-[50%_70%]\"\n                  style={{ fontVariationSettings: rest.fontVariationSettings }}\n                >\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/proximity-text"
}
