{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lens-text",
  "type": "registry:ui",
  "title": "Lens Text",
  "description": "Text held out of focus until the pointer passes over it like a magnifying glass.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/loomui/lens-text.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface LensTextProps extends React.ComponentProps<\"span\"> {\n  /** Blur radius of the resting text, in pixels. */\n  blur?: number\n  /** Diameter of the lens in pixels. */\n  size?: number\n  /** How much of the lens fades out at its edge, from `0` to `1`. */\n  feather?: number\n  /** Scale applied to the text under the lens. `1` is a plain focus window. */\n  magnify?: number\n  /** Corner radius of the frosted panel. Any CSS length; `em` scales with the type. */\n  radius?: string\n  /**\n   * Milliseconds the lens takes to catch up to the pointer, and the pace it\n   * opens and closes at. `0` locks it to the pointer.\n   */\n  follow?: number\n  /** Draw a hairline ring around the lens. */\n  ring?: boolean\n  /** Render the text sharp, with no blur and no lens. */\n  disabled?: boolean\n}\n\n/**\n * A hovering pointer is the only way to aim the lens, so anything else (touch,\n * pen, keyboard-only) gets the text sharp rather than never getting it at all.\n */\nconst HOVER_QUERY = \"(hover: hover) and (pointer: fine)\"\nconst MOTION_QUERY = \"(prefers-reduced-motion: reduce)\"\n\n/** The lens opens slower than it tracks, or it pops on the way in. */\nconst OPEN_FACTOR = 1.6\n\n/**\n * Room for the blur to fall off inside the panel rather than at its edge.\n * In `em` so it tracks the type it is wrapped around.\n */\nconst PANEL_PADDING = \"0.28em 0.45em\"\n\nfunction useMediaQuery(query: string, fallback: boolean) {\n  // Server and first client paint agree on `fallback`. The effect corrects it\n  // before anyone can interact, so there is no hydration mismatch.\n  const [matches, setMatches] = React.useState(fallback)\n\n  React.useEffect(() => {\n    const list = window.matchMedia(query)\n    const sync = () => setMatches(list.matches)\n\n    sync()\n    list.addEventListener(\"change\", sync)\n    return () => list.removeEventListener(\"change\", sync)\n  }, [query])\n\n  return matches\n}\n\nfunction clamp01(value: number) {\n  return Math.min(Math.max(value, 0), 1)\n}\n\nexport function LensText({\n  children,\n  className,\n  blur = 8,\n  size = 110,\n  feather = 0.5,\n  magnify = 1,\n  radius = \"0.35em\",\n  follow = 130,\n  ring = false,\n  disabled = false,\n  style,\n  ...props\n}: LensTextProps) {\n  const ref = React.useRef<HTMLSpanElement>(null)\n  const frame = React.useRef(0)\n  const running = React.useRef(false)\n  const hasHover = useMediaQuery(HOVER_QUERY, true)\n  const reduceMotion = useMediaQuery(MOTION_QUERY, false)\n\n  const lensRadius = size / 2\n  const tau = reduceMotion ? 0 : Math.max(follow, 0)\n\n  // Everything the animation reads and writes lives in one ref. The lens runs\n  // entirely on CSS custom properties, so a pointer crossing the text costs\n  // zero renders.\n  const lens = React.useRef({\n    x: 0,\n    y: 0,\n    targetX: 0,\n    targetY: 0,\n    radius: 0,\n    targetRadius: 0,\n    last: 0,\n  })\n\n  const write = React.useCallback((open: number) => {\n    const node = ref.current\n    const state = lens.current\n    if (!node) {\n      return\n    }\n\n    node.style.setProperty(\"--lens-x\", `${state.x}px`)\n    node.style.setProperty(\"--lens-y\", `${state.y}px`)\n    node.style.setProperty(\"--lens-radius\", `${state.radius}px`)\n    node.style.setProperty(\"--lens-open\", `${open}`)\n  }, [])\n\n  const tick = React.useCallback(\n    (now: number) => {\n      const state = lens.current\n      const elapsed = state.last ? Math.min(now - state.last, 64) : 16\n      state.last = now\n\n      // Exponential ease toward the target. Framing it as a time constant\n      // rather than a per-frame fraction keeps the feel identical at 60Hz and\n      // at 120Hz.\n      const move = tau === 0 ? 1 : 1 - Math.exp(-elapsed / tau)\n      const open = tau === 0 ? 1 : 1 - Math.exp(-elapsed / (tau * OPEN_FACTOR))\n\n      state.x += (state.targetX - state.x) * move\n      state.y += (state.targetY - state.y) * move\n      state.radius += (state.targetRadius - state.radius) * open\n\n      const settled =\n        Math.abs(state.targetRadius - state.radius) < 0.25 &&\n        Math.abs(state.targetX - state.x) < 0.25 &&\n        Math.abs(state.targetY - state.y) < 0.25\n\n      if (settled) {\n        state.x = state.targetX\n        state.y = state.targetY\n        state.radius = state.targetRadius\n        state.last = 0\n        running.current = false\n      }\n\n      write(lensRadius === 0 ? 0 : clamp01(state.radius / lensRadius))\n\n      if (!settled) {\n        frame.current = requestAnimationFrame(tick)\n      }\n    },\n    [lensRadius, tau, write]\n  )\n\n  // A single loop runs until the lens settles. Pointer events only move the\n  // target, so a fast mouse cannot stack frames or reset the timing.\n  const start = React.useCallback(() => {\n    if (running.current) {\n      return\n    }\n\n    running.current = true\n    lens.current.last = 0\n    frame.current = requestAnimationFrame(tick)\n  }, [tick])\n\n  const point = React.useCallback(\n    (event: React.PointerEvent<HTMLSpanElement>, snap: boolean) => {\n      const node = ref.current\n      if (!node) {\n        return\n      }\n\n      const rect = node.getBoundingClientRect()\n      const state = lens.current\n      state.targetX = event.clientX - rect.left\n      state.targetY = event.clientY - rect.top\n      state.targetRadius = lensRadius\n\n      // On the way in the lens opens where the pointer already is. Sliding it\n      // across from wherever it was left last time reads as a bug.\n      if (snap) {\n        state.x = state.targetX\n        state.y = state.targetY\n      }\n\n      start()\n    },\n    [lensRadius, start]\n  )\n\n  const handleLeave = React.useCallback(() => {\n    lens.current.targetRadius = 0\n    start()\n  }, [start])\n\n  React.useEffect(\n    () => () => {\n      cancelAnimationFrame(frame.current)\n      running.current = false\n    },\n    []\n  )\n\n  if (disabled || !hasHover) {\n    return (\n      <span\n        data-slot=\"lens-text\"\n        className={className}\n        style={style}\n        {...props}\n      >\n        {children}\n      </span>\n    )\n  }\n\n  // Where the lens stops being fully opaque. At `feather: 0` the edge is a\n  // hard cut. At `1` it falls off from the centre.\n  const core = `${Math.round((1 - clamp01(feather)) * 100)}%`\n  const at = \"var(--lens-x, 50%) var(--lens-y, 50%)\"\n\n  return (\n    <span\n      ref={ref}\n      data-slot=\"lens-text\"\n      onPointerEnter={(event) => point(event, true)}\n      onPointerMove={(event) => point(event, false)}\n      onPointerLeave={handleLeave}\n      className={cn(\"relative isolate inline-block\", className)}\n      style={\n        {\n          \"--lens\": `radial-gradient(var(--lens-radius, 0px) circle at ${at}, #000 ${core}, transparent 100%)`,\n          \"--lens-hole\": `radial-gradient(var(--lens-radius, 0px) circle at ${at}, transparent ${core}, #000 100%)`,\n          ...style,\n        } as React.CSSProperties\n      }\n      {...props}\n    >\n      {/* The blurred copy is the real text: filters are invisible to assistive\n          technology, so the accessible name and the selection stay here. The\n          lens is punched out of it, or the sharp copy would sit on top of a\n          blurred one and read as a halo.\n\n          The blur goes on the inner span and the panel does the clipping.\n          Blurring the panel itself would spread the glow past its own edges,\n          where the mask cuts it off square, and a pane of frosted glass with\n          four hard corners is the tell that this is two divs. */}\n      <span\n        className=\"block overflow-hidden [mask-image:var(--lens-hole)] [-webkit-mask-image:var(--lens-hole)]\"\n        style={{ padding: PANEL_PADDING, borderRadius: radius }}\n      >\n        <span className=\"block\" style={{ filter: `blur(${blur}px)` }}>\n          {children}\n        </span>\n      </span>\n\n      {/* The sharp copy is a duplicate clipped to the lens. `inset-0` and the\n          same padding put it exactly over the blurred one, so both wrap on the\n          same words. Nothing here fades: the lens radius is what animates, and\n          both layers read it from the same custom property, so the hole and\n          the fill always agree. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block [mask-image:var(--lens)] select-none [-webkit-mask-image:var(--lens)]\"\n        style={{\n          padding: PANEL_PADDING,\n          transform: magnify === 1 ? undefined : `scale(${magnify})`,\n          transformOrigin: at,\n        }}\n      >\n        {children}\n      </span>\n\n      {ring ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full border\"\n          style={{\n            top: \"var(--lens-y, 50%)\",\n            left: \"var(--lens-x, 50%)\",\n            height: \"calc(var(--lens-radius, 0px) * 2)\",\n            width: \"calc(var(--lens-radius, 0px) * 2)\",\n            opacity: \"var(--lens-open, 0)\",\n            borderColor: \"color-mix(in oklch, currentColor 20%, transparent)\",\n          }}\n        />\n      ) : null}\n    </span>\n  )\n}\n",
      "type": "registry:ui"
    }
  ]
}