{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-prism-split",
  "title": "Text Prism Split",
  "description": "A frosted prism strip rides the cursor across a headline, magnifying the text beneath it and tearing it into velocity-driven RGB channel slices that snap back with spring overshoot.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/text-prism-split/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// A frosted prism strip rides the cursor across a headline. Text behind the\n// strip magnifies about the strip center and tears into offset RGB channel\n// slices (screen-blended clones) proportional to drag velocity — light\n// through cut glass, spatial and physical, not a global hover filter. Each\n// glyph snaps back with spring overshoot as the strip clears it. Manual\n// spring integrator on a direct-DOM rAF loop; no React state on the hot\n// path; the loop sleeps when settled.\nexport function PrismDragSplit({\n  text = \"REFRACTION\",\n  stripWidth = 120,\n  stiffness = 300,\n  damping = 24,\n  mass = 1,\n  dispersionGain = 0.018,\n  maxDispersion = 14,\n  magnify = 1.06,\n  className = \"\",\n}: {\n  /** the rendered text */\n  text?: string;\n  /** prism strip width in px */\n  stripWidth?: number;\n  /** spring stiffness (N/m-ish, px units) */\n  stiffness?: number;\n  /** spring damping coefficient */\n  damping?: number;\n  /** spring mass */\n  mass?: number;\n  /** px of red-channel offset per px/s of strip velocity */\n  dispersionGain?: number;\n  /** channel offset clamp in px */\n  maxDispersion?: number;\n  /** scale of the refracted clones inside the strip */\n  magnify?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const stripRef = useRef<HTMLDivElement>(null);\n  const baseRef = useRef<HTMLSpanElement>(null);\n  const redRef = useRef<HTMLDivElement>(null);\n  const greenRef = useRef<HTMLDivElement>(null);\n  const blueRef = useRef<HTMLDivElement>(null);\n  const charRefs = useRef<(HTMLSpanElement | null)[]>([]);\n\n  const glyphs = Array.from(text);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    const strip = stripRef.current;\n    const base = baseRef.current;\n    const red = redRef.current;\n    const green = greenRef.current;\n    const blue = blueRef.current;\n    if (!container || !strip || !base || !red || !green || !blue) return;\n\n    const chars = charRefs.current\n      .slice(0, Array.from(text).length)\n      .filter((el): el is HTMLSpanElement => el !== null);\n    const channels: [HTMLDivElement, number][] = [\n      [red, 1],\n      [green, 0],\n      [blue, -1],\n    ];\n\n    let width = 0;\n    let baseX = 0;\n    let centers: number[] = [];\n\n    // untransformed offsets — clones are pinned to the base headline's box so\n    // the counter-translate keeps glyphs registered regardless of padding\n    const measure = () => {\n      width = container.offsetWidth;\n      baseX = base.offsetLeft;\n      const baseY = base.offsetTop;\n      centers = chars.map((el) => el.offsetLeft + el.offsetWidth / 2);\n      for (const [el] of channels) {\n        el.style.left = `${baseX}px`;\n        el.style.top = `${baseY}px`;\n      }\n    };\n\n    // strip at x; clones counter-translated by -x and scaled about the strip\n    // center so the glyph under the center stays registered while neighbors\n    // bulge outward — a lens, not a sticker\n    const apply = (x: number, dx: number) => {\n      strip.style.transform = `translate3d(${x}px,0,0)`;\n      const origin = `${x + stripWidth / 2 - baseX}px 50%`;\n      for (const [el, sign] of channels) {\n        el.style.transform = `translateX(${-x + dx * sign}px) scale(${magnify})`;\n        el.style.transformOrigin = origin;\n      }\n    };\n\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n      // no rAF: strip pinned at 38% with fixed channel offsets — the frozen\n      // dispersion still reads as the concept\n      const place = () => {\n        measure();\n        apply(width * 0.38, 6);\n      };\n      place();\n      const ro = new ResizeObserver(place);\n      ro.observe(container);\n      return () => ro.disconnect();\n    }\n\n    measure();\n    let x = width / 2 - stripWidth / 2;\n    let v = 0;\n    let target = x;\n    let raf = 0;\n    let last = 0;\n    let covered = centers.map((c) => c > x && c < x + stripWidth);\n    apply(x, 0);\n\n    const loop = (now: number) => {\n      const dt = Math.min(0.032, Math.max(0.001, (now - last) / 1000));\n      last = now;\n      // semi-implicit Euler spring integrator\n      const accel = (stiffness * (target - x) - damping * v) / mass;\n      v += accel * dt;\n      x += v * dt;\n      // velocity-proportional dispersion: stationary = converged near-white,\n      // dragging tears the channels apart\n      const dx = Math.max(-maxDispersion, Math.min(maxDispersion, v * dispersionGain));\n\n      // glyph snap-back the instant the strip clears a char center\n      for (let i = 0; i < centers.length; i++) {\n        const inside = centers[i] > x && centers[i] < x + stripWidth;\n        if (covered[i] && !inside) {\n          const el = chars[i];\n          el.style.setProperty(\"--nsui-psd-dx\", `${v >= 0 ? 6 : -6}px`);\n          el.classList.remove(\"nsui-psd-snap\");\n          void el.offsetWidth; // restart the overshoot animation\n          el.classList.add(\"nsui-psd-snap\");\n        }\n        covered[i] = inside;\n      }\n\n      if (Math.abs(v) < 2 && Math.abs(target - x) < 0.25) {\n        x = target;\n        v = 0;\n        apply(x, 0);\n        raf = 0; // sleep\n      } else {\n        apply(x, dx);\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const wake = () => {\n      if (!raf) {\n        last = performance.now();\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    const clampTarget = (t: number) =>\n      Math.min(width - stripWidth / 2, Math.max(-stripWidth / 2, t));\n\n    const onMove = (e: PointerEvent) => {\n      const left = container.getBoundingClientRect().left;\n      target = clampTarget(e.clientX - left - stripWidth / 2);\n      wake();\n    };\n    const onLeave = () => {\n      target = width / 2 - stripWidth / 2; // spring back to center rest\n      wake();\n    };\n    const ro = new ResizeObserver(() => {\n      measure();\n      target = clampTarget(target);\n      covered = centers.map((c) => c > x && c < x + stripWidth);\n      apply(x, 0);\n    });\n\n    ro.observe(container);\n    container.addEventListener(\"pointermove\", onMove);\n    container.addEventListener(\"pointerleave\", onLeave);\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      container.removeEventListener(\"pointermove\", onMove);\n      container.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [text, stripWidth, stiffness, damping, mass, dispersionGain, maxDispersion, magnify]);\n\n  return (\n    <div\n      ref={containerRef}\n      className={`relative inline-block cursor-ew-resize touch-none select-none font-sans font-semibold text-foreground ${className}`}\n      // kerning/ligatures off so the per-char base and the continuous-text\n      // clones produce byte-identical layout — registration depends on it\n      style={{\n        fontKerning: \"none\",\n        fontVariantLigatures: \"none\",\n        fontFeatureSettings: '\"liga\" 0, \"kern\" 0',\n      }}\n    >\n      <style>{`@keyframes nsui-psd-snap{from{transform:translateX(var(--nsui-psd-dx,6px))}to{transform:translateX(0)}}.nsui-psd-snap{animation:nsui-psd-snap 350ms cubic-bezier(0.34,1.56,0.64,1)}`}</style>\n\n      {/* base headline, split per-char for the snap-back */}\n      <span ref={baseRef} aria-label={text} role=\"text\" className=\"whitespace-pre\">\n        {glyphs.map((g, i) => (\n          <span\n            key={i}\n            aria-hidden\n            ref={(el) => {\n              charRefs.current[i] = el;\n            }}\n            className=\"inline-block will-change-transform\"\n          >\n            {g === \" \" ? \" \" : g}\n          </span>\n        ))}\n      </span>\n\n      {/* prism strip: frosted slab that occludes the base beneath it */}\n      <div\n        ref={stripRef}\n        aria-hidden\n        className=\"pointer-events-none absolute inset-y-0 left-0 isolate overflow-hidden border-x border-black/10 dark:border-white/10 bg-background/85 backdrop-blur-md will-change-transform shadow-[inset_0_1px_0_0_rgba(0,0,0,0.06),inset_0_-1px_0_0_rgba(0,0,0,0.03),0_8px_24px_-8px_rgba(0,0,0,0.25)] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_-1px_0_0_rgba(255,255,255,0.06),0_8px_24px_-8px_rgba(0,0,0,0.5)]\"\n        style={{ width: stripWidth }}\n      >\n        {/* refracted content, edge-faded so glyphs melt in instead of hard-clipping.\n            Forced dark tint (not theme-token-driven) so the mix-blend-screen RGB\n            clones always pop against a near-black backdrop — screen blending\n            against a near-white light-mode background would wash channel text\n            out to invisible. */}\n        <div className=\"absolute inset-0 bg-[#0a0a0a]/90 [mask-image:linear-gradient(to_right,transparent,black_12px,black_calc(100%-12px),transparent)]\">\n          {(\n            [\n              [redRef, \"#ff0000\"],\n              [greenRef, \"#00ff00\"],\n              [blueRef, \"#0000ff\"],\n            ] as const\n          ).map(([ref, color]) => (\n            <div\n              key={color}\n              ref={ref}\n              className=\"absolute left-0 top-0 whitespace-pre mix-blend-screen will-change-transform\"\n              style={{ color }}\n            >\n              {text.replace(/ /g, \" \")}\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/text-prism-split.tsx"
    }
  ],
  "meta": {
    "collection": "loud",
    "tags": [
      "cursor",
      "text",
      "rgb-split",
      "chromatic-aberration",
      "glass",
      "spring",
      "headline"
    ],
    "instruction": "A frosted prism strip that rides the cursor across a headline and refracts the text beneath it — spatial, physical refraction, not a global hover filter. Pure DOM, zero deps. Base headline (Geist Sans 600) split into per-char spans with kerning and ligatures disabled so split and continuous layouts register exactly. The prism is an absolutely-positioned 120px strip: overflow-hidden, backdrop-blur-md, bg-background/85 occluding fill, a 1px border (black/10 in light, white/10 in dark) and inset specular shadow (dark or light highlight per theme) per the glass recipe, and a 12px mask fade on both vertical content edges. The refracted-content layer inside the strip carries a fixed near-black tint (bg-[#0a0a0a]/90, independent of the page theme) so the three full-width clones of the headline in pure #ff0000/#00ff00/#0000ff with mix-blend-screen recombine to near-white where aligned in both light and dark mode — screen blending onto a near-white light-mode surface would otherwise wash the channel text to invisible. Each clone is scaled 1.06 about the strip's current center and counter-translated by -stripX so glyphs stay registered with the base — a lens, not a sticker. The strip follows the cursor via a manual spring integrator (stiffness 300, damping 24, mass 1, semi-implicit Euler) in a rAF loop with position and velocity held in refs and styles written via el.style.transform — no React state on the hot path, loop sleeps when settled. Channel dispersion is velocity-proportional: red dx = clamp(v × 0.018, ±14px), blue mirrored, green fixed, so a stationary strip shows converged text and dragging tears it into RGB slices. When the strip clears a glyph's center the rAF loop toggles a class on that span directly, springing it from a 6px offset (signed by travel direction) back to rest over 350ms with cubic-bezier(0.34,1.56,0.64,1) overshoot. On pointerleave the strip springs back to center rest. Under prefers-reduced-motion there is no rAF: the strip pins statically at 38% of the headline with fixed ±6px channel offsets so the frozen dispersion still reads as the concept.",
    "rank": 19
  },
  "type": "registry:ui"
}