{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"read-focus","type":"registry:ui","title":"Read Focus","description":"A paragraph whose words sharpen from dim and blurred to crisp as it scrolls through view.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/read-focus.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { Fragment, useEffect, useRef, type RefObject } from \"react\";\n\nexport interface ReadFocusProps {\n  text: string;\n  /** Element that scrolls. Defaults to the window. */\n  scrollContainerRef?: RefObject<HTMLElement | null>;\n  /** Opacity of words that are not revealed yet (0-1). */\n  baseOpacity?: number;\n  /** Blur of unrevealed words, in px. */\n  blur?: number;\n  /** How many words are mid-transition at once. Higher is a softer sweep. */\n  softness?: number;\n  /** Degrees the block starts tilted, settling to 0 as it reveals. */\n  rotation?: number;\n  as?: \"p\" | \"h2\" | \"h3\" | \"div\";\n  className?: string;\n}\n\nconst clamp = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);\n\nexport function ReadFocus({\n  text,\n  scrollContainerRef,\n  baseOpacity = 0.12,\n  blur = 8,\n  softness = 6,\n  rotation = 3,\n  as: Tag = \"p\",\n  className,\n}: ReadFocusProps) {\n  const ref = useRef<HTMLElement>(null);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const words = Array.from(el.querySelectorAll<HTMLElement>(\"[data-word]\"));\n    const scroller = scrollContainerRef?.current ?? null;\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const soft = Math.max(1, softness);\n    let raf = 0;\n\n    const update = () => {\n      raf = 0;\n      const view = scroller ? scroller.getBoundingClientRect() : { top: 0, height: window.innerHeight };\n      const r = el.getBoundingClientRect();\n      // 0 when the block's top crosses 90% of the view, 1 when its bottom crosses 45%.\n      const p = reduced ? 1 : clamp((view.height * 0.9 - (r.top - view.top)) / (view.height * 0.45 + r.height));\n      const head = p * (words.length + soft);\n      words.forEach((w, i) => {\n        const t = clamp((head - i) / soft);\n        w.style.opacity = String(baseOpacity + (1 - baseOpacity) * t);\n        w.style.filter = t >= 1 || blur === 0 ? \"none\" : `blur(${((1 - t) * blur).toFixed(2)}px)`;\n      });\n      el.style.transform = rotation ? `rotate(${((1 - p) * rotation).toFixed(3)}deg)` : \"\";\n    };\n    const onScroll = () => {\n      if (!raf) raf = requestAnimationFrame(update);\n    };\n\n    update();\n    const target: HTMLElement | Window = scroller ?? window;\n    target.addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"resize\", onScroll);\n    return () => {\n      cancelAnimationFrame(raf);\n      target.removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"resize\", onScroll);\n    };\n  }, [scrollContainerRef, baseOpacity, blur, softness, rotation, text]);\n\n  const words = text.split(/\\s+/).filter(Boolean);\n\n  return (\n    <Tag ref={ref as never} className={className} style={{ transformOrigin: \"0% 50%\", willChange: \"transform\" }}>\n      <span className=\"sr-only\">{text}</span>\n      {words.map((word, i) => (\n        <Fragment key={i}>\n          <span\n            data-word\n            aria-hidden\n            style={{ display: \"inline-block\", opacity: baseOpacity, filter: `blur(${blur}px)`, willChange: \"opacity, filter\" }}\n          >\n            {word}\n          </span>\n          {i < words.length - 1 && \" \"}\n        </Fragment>\n      ))}\n    </Tag>\n  );\n}\n"}]}