{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-captions",
  "title": "Scroll Captions",
  "description": "Captions that take turns in one place as the reader scrolls, on a pinned stage: the old one fades out quickly, the new one fades in while slowly growing. Pins to its scroll container or the page. No dependencies.",
  "files": [
    {
      "path": "registry/mewo/ui/scroll-captions.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface ScrollCaptionsProps extends Omit<React.ComponentProps<\"section\">, \"children\"> {\n  /** The captions, shown one at a time in the same place as the reader scrolls. */\n  captions?: React.ReactNode[]\n  /** Viewport heights of scroll each caption holds. */\n  step?: number\n  /** Merged onto every caption: its place, width and type. */\n  captionClassName?: string\n  /** Called with the index of the caption that comes up (e.g. to move a timeline beside it). */\n  onCaptionChange?: (index: number) => void\n}\n\n/** The nearest ancestor that scrolls vertically, or null when the page itself does. */\nfunction scrollParent(node: HTMLElement): HTMLElement | null {\n  for (let el = node.parentElement; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {\n    const { overflowY } = getComputedStyle(el)\n    if (overflowY === \"auto\" || overflowY === \"scroll\") return el\n  }\n  return null\n}\n\n/** The caption that shows `progress` viewport heights after the stage pins: none until the section is half in view,\n * then one per `step`, the last one held to the end. */\nexport function captionAt(progress: number, count: number, step: number) {\n  if (count === 0 || progress < -0.5) return -1\n  return Math.min(count - 1, Math.max(0, Math.floor(progress / Math.max(0.01, step))))\n}\n\n/**\n * Captions that take turns in one place as the reader scrolls, on a pinned stage. The old one fades out quickly,\n * then the new one fades in while growing from 90%, fast at first and ever slower over five seconds, so two never\n * overlap. Pins to its nearest scroll container, or to the page. Changes are announced politely to screen readers.\n */\nexport function ScrollCaptions({\n  captions = [\n    <>Around 3000 BCE, a sculptor carved a tall <em>vessel</em> from stone.</>,\n    \"Centuries later, it was buried with the temple that kept it.\",\n    \"In the 1930s, an expedition found it, broken, with an ancient repair.\",\n    \"Today it stands in a museum, its long journey written beside it.\",\n  ],\n  step = 0.85,\n  captionClassName,\n  onCaptionChange,\n  className,\n  style,\n  ...props\n}: ScrollCaptionsProps) {\n  const stageRef = React.useRef<HTMLDivElement>(null)\n  const onCaptionChangeRef = React.useRef(onCaptionChange)\n  const [active, setActive] = React.useState(-1)\n  const count = captions.length\n\n  React.useEffect(() => {\n    onCaptionChangeRef.current = onCaptionChange\n  })\n\n  React.useEffect(() => {\n    const stage = stageRef.current\n    // The root's ref stays the consumer's, so the section is reached through the stage.\n    const section = stage?.parentElement\n    if (!stage || !section) return\n    const scroller = scrollParent(section)\n    let shown = -1\n    let raf = 0\n\n    const update = () => {\n      raf = 0\n      // Inside a scroll container the stage pins to that container, not the window.\n      if (scroller) section.style.setProperty(\"--mewo-viewport\", `${scroller.clientHeight}px`)\n      const viewport = scroller ? scroller.clientHeight : window.innerHeight\n      const top = section.getBoundingClientRect().top - (scroller ? scroller.getBoundingClientRect().top : 0)\n      const next = captionAt(-top / Math.max(1, viewport), count, step)\n      if (next === shown) return\n      shown = next\n      setActive(next)\n      if (next >= 0) onCaptionChangeRef.current?.(next)\n    }\n    const schedule = () => {\n      if (!raf) raf = requestAnimationFrame(update)\n    }\n\n    schedule()\n    const target = scroller ?? window\n    target.addEventListener(\"scroll\", schedule, { passive: true })\n    window.addEventListener(\"resize\", schedule)\n    return () => {\n      cancelAnimationFrame(raf)\n      target.removeEventListener(\"scroll\", schedule)\n      window.removeEventListener(\"resize\", schedule)\n    }\n  }, [count, step])\n\n  return (\n    <section\n      data-slot=\"scroll-captions\"\n      className={cn(\"relative h-[calc(var(--mewo-viewport,100vh)*var(--mewo-length))]\", className)}\n      style={{ \"--mewo-length\": 1 + count * Math.max(0, step), ...style } as React.CSSProperties}\n      {...props}\n    >\n      <div ref={stageRef} aria-live=\"polite\" className=\"sticky top-0 h-[var(--mewo-viewport,100vh)] w-full overflow-hidden [container-type:size]\">\n        {captions.map((caption, i) => (\n          <div\n            key={i}\n            data-slot=\"scroll-captions-caption\"\n            data-on={i === active ? \"\" : undefined}\n            className={cn(\n              \"absolute top-1/2 left-1/2 w-[min(68cqh,72cqw)] -translate-x-1/2 -translate-y-1/2 text-center font-heading text-[min(3.35cqh,5.4cqw)] leading-[1.12] tracking-[-0.01em]\",\n              // leaving: a quick fade, then back to 90% out of sight\n              \"invisible scale-90 opacity-0 [transition:opacity_0.22s_ease-in,scale_0s_linear_0.22s,visibility_0s_linear_0.22s]\",\n              // coming up: after the old one has gone, a fade in and a long, slowing growth\n              \"data-on:visible data-on:scale-100 data-on:opacity-100 data-on:[transition:opacity_1.2s_cubic-bezier(0.215,0.61,0.355,1)_0.25s,scale_5s_cubic-bezier(0.19,1,0.22,1)_0.25s,visibility_0s]\",\n              // as specific as the data-on transition, so reduced motion wins over it\n              \"motion-reduce:scale-100 motion-reduce:transition-none motion-reduce:data-on:transition-none\",\n              captionClassName\n            )}\n          >\n            {caption}\n          </div>\n        ))}\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "added": "2026-10-27"
  },
  "categories": [
    "scroll",
    "text"
  ],
  "type": "registry:ui"
}