{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scroll-story","type":"registry:component","title":"Scroll story","description":"A how-it-works section that tells itself as you scroll: steps light up at the middle, a rail fills, and one card reshapes into each step’s picture.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/scroll-story.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * SCROLL STORY: a how-it-works that tells itself as you scroll\n *\n *   steps     the words scroll past a picture that stays put; the\n *             step crossing the middle of the view lights up and\n *             the others wait at a third of their strength\n *   rail      a hairline down the steps fills with the scroll,\n *             continuously, and each step's dot fills as the line\n *             reaches it\n *   picture   one card that reshapes to each step's picture: its\n *             size eases from one to the next while the old\n *             picture blurs out and the new one rises in (and\n *             plays whatever it plays when it arrives). \"02 / 04\"\n *             rolls above it\n *   narrow    the picture sticks to the top and the steps slide\n *             underneath it\n *   jump      tapping a step scrolls it to the middle\n *\n * Works with the page's scroll or inside any scrolling box.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ScrollStoryStep {\n  title: string;\n  body?: React.ReactNode;\n  /** Mounted when its step arrives, so any animation in it plays then. */\n  visual: React.ReactNode;\n}\n\nexport interface ScrollStoryProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  steps: ScrollStoryStep[];\n  /** Where the picture sits when there's room for two columns. */\n  side?: \"start\" | \"end\";\n  onStepChange?: (index: number) => void;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst WIDE = 520;\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** The nearest box that scrolls vertically, or null for the page. */\nfunction scrollParent(el: HTMLElement | null) {\n  for (let p = el?.parentElement; p && p !== document.body; p = p.parentElement) {\n    if (/(auto|scroll)/.test(getComputedStyle(p).overflowY)) return p;\n  }\n  return null;\n}\n\n/** The card that changes shape between pictures. */\nfunction Stage({ index, children, reduced }: { index: number; children: React.ReactNode; reduced: boolean }) {\n  const innerRef = React.useRef<HTMLDivElement>(null);\n  const [size, setSize] = React.useState<{ w: number; h: number } | null>(null);\n  const [ghost, setGhost] = React.useState<{ key: number; node: React.ReactNode } | null>(null);\n  const last = React.useRef({ index, node: children });\n\n  React.useLayoutEffect(() => {\n    const el = innerRef.current;\n    if (!el) return;\n    const measure = () => setSize({ w: el.offsetWidth, h: el.offsetHeight });\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [index]);\n\n  // A new picture: keep the old one a moment to blur out underneath, raise the new one.\n  React.useLayoutEffect(() => {\n    if (index === last.current.index) {\n      last.current.node = children;\n      return;\n    }\n    if (!reduced) setGhost({ key: last.current.index, node: last.current.node });\n    last.current = { index, node: children };\n    if (reduced) return;\n    innerRef.current?.animate(\n      [\n        { opacity: 0, transform: \"translateY(10px) scale(0.98)\", filter: \"blur(4px)\" },\n        { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n      ],\n      { duration: 420, delay: 90, easing: EASE, fill: \"backwards\" },\n    );\n  }, [index, children, reduced]);\n\n  return (\n    <div\n      className=\"relative overflow-hidden rounded-[20px] border border-border bg-card\"\n      style={{ width: size?.w, height: size?.h, transition: reduced || !size ? \"none\" : `width 460ms ${EASE}, height 460ms ${EASE}` }}\n    >\n      {ghost && (\n        <div\n          key={`ghost-${ghost.key}`}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 top-0 w-max\"\n          ref={(el) => {\n            if (!el || el.dataset.out) return;\n            el.dataset.out = \"1\";\n            const a = el.animate(\n              [\n                { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n                { opacity: 0, filter: \"blur(4px)\", transform: \"scale(0.97)\" },\n              ],\n              { duration: 240, easing: EASE, fill: \"forwards\" },\n            );\n            a.onfinish = () => setGhost((g) => (g?.key === ghost.key ? null : g));\n          }}\n        >\n          {ghost.node}\n        </div>\n      )}\n      <div key={index} ref={innerRef} className=\"w-max\">\n        {children}\n      </div>\n    </div>\n  );\n}\n\nexport function ScrollStory({ steps, side = \"end\", onStepChange, className = \"\", style, ...props }: ScrollStoryProps) {\n  const reduced = useReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const stickyRef = React.useRef<HTMLDivElement>(null);\n  const trackRef = React.useRef<HTMLSpanElement>(null);\n  const fillRef = React.useRef<HTMLSpanElement>(null);\n  const stepRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const dotRefs = React.useRef<(HTMLSpanElement | null)[]>([]);\n  const scroller = React.useRef<HTMLElement | null>(null);\n  const [active, setActive] = React.useState(0);\n  const [viewport, setViewport] = React.useState(0);\n  const activeRef = React.useRef(0);\n  const changed = React.useRef(onStepChange);\n  changed.current = onStepChange;\n\n  // Where the reading line is: the middle of the view, or the middle of what the sticky picture leaves.\n  const line = () => {\n    const box = scroller.current?.getBoundingClientRect();\n    const top = box ? box.top : 0;\n    const height = scroller.current ? scroller.current.clientHeight : window.innerHeight;\n    const wide = (rootRef.current?.clientWidth ?? 0) >= WIDE;\n    const stuck = wide ? 0 : (stickyRef.current?.offsetHeight ?? 0);\n    return top + stuck + (height - stuck) * (wide ? 0.5 : 0.42);\n  };\n\n  // On scroll: the rail fills to the line (written straight to the DOM), and the step at the line becomes active.\n  const update = React.useCallback(() => {\n    const list = listRef.current;\n    const first = dotRefs.current[0];\n    const last = dotRefs.current[steps.length - 1];\n    if (!list || !first || !last) return;\n    const y = line();\n    const origin = list.getBoundingClientRect().top;\n    const a = first.getBoundingClientRect().top + first.offsetHeight / 2 - origin;\n    const b = last.getBoundingClientRect().top + last.offsetHeight / 2 - origin;\n    if (trackRef.current) Object.assign(trackRef.current.style, { top: `${a}px`, height: `${b - a}px` });\n    if (fillRef.current) Object.assign(fillRef.current.style, { top: `${a}px`, height: `${Math.min(b - a, Math.max(0, y - origin - a))}px` });\n    let next = 0;\n    stepRefs.current.forEach((el, i) => {\n      if (el && el.getBoundingClientRect().top <= y) next = i;\n    });\n    if (next !== activeRef.current) {\n      activeRef.current = next;\n      setActive(next);\n      changed.current?.(next);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [steps.length]);\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    scroller.current = scrollParent(root);\n    const target: HTMLElement | Window = scroller.current ?? window;\n    let frame = 0;\n    const onScroll = () => {\n      if (frame) return;\n      frame = requestAnimationFrame(() => {\n        frame = 0;\n        update();\n      });\n    };\n    const measure = () => {\n      setViewport(scroller.current ? scroller.current.clientHeight : window.innerHeight);\n      onScroll();\n    };\n    measure();\n    target.addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"resize\", measure);\n    const observer = new ResizeObserver(measure);\n    observer.observe(root);\n    if (scroller.current) observer.observe(scroller.current);\n    return () => {\n      cancelAnimationFrame(frame);\n      target.removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"resize\", measure);\n      observer.disconnect();\n    };\n  }, [update]);\n\n  const jump = (i: number) => {\n    const el = stepRefs.current[i];\n    if (!el) return;\n    const delta = el.getBoundingClientRect().top - line() + 2;\n    const behavior: ScrollBehavior = reduced ? \"auto\" : \"smooth\";\n    if (scroller.current) scroller.current.scrollBy({ top: delta, behavior });\n    else window.scrollBy({ top: delta, behavior });\n  };\n\n  const pad = (n: number) => String(n).padStart(2, \"0\");\n\n  return (\n    <div\n      ref={rootRef}\n      className={`@container ${className}`}\n      style={{ ...style, [\"--story-h\" as string]: `${viewport || 600}px` }}\n      {...props}\n    >\n      <div className=\"grid @min-[520px]:grid-cols-2 @min-[520px]:gap-10\">\n        {/* The picture: beside the steps when there's room, stuck to the top when there isn't. */}\n        <div\n          ref={stickyRef}\n          className={`sticky top-0 z-10 -mx-2 flex h-[calc(var(--story-h)*0.46)] flex-col items-center justify-center gap-3 border-b border-border bg-background px-2 @min-[520px]:mx-0 @min-[520px]:h-[var(--story-h)] @min-[520px]:border-b-0 @min-[520px]:bg-transparent @min-[520px]:px-0 ${\n            side === \"end\" ? \"@min-[520px]:order-last\" : \"\"\n          }`}\n        >\n          <p className=\"text-[11.5px] tabular-nums text-muted-foreground\" aria-hidden=\"true\">\n            <NumberRoll value={active + 1} format={{ minimumIntegerDigits: 2 }} duration={500} /> / {pad(steps.length)}\n          </p>\n          <Stage index={active} reduced={reduced}>\n            {steps[active]?.visual}\n          </Stage>\n        </div>\n\n        <div\n          ref={listRef}\n          className=\"relative pb-[calc(var(--story-h)*0.12)] pt-8 @min-[520px]:pt-[calc(var(--story-h)*0.38)]\"\n        >\n          <span ref={trackRef} aria-hidden=\"true\" className=\"absolute left-[5px] w-px bg-border\" />\n          <span ref={fillRef} aria-hidden=\"true\" className=\"absolute left-[5px] w-px bg-foreground\" />\n          {steps.map((s, i) => {\n            const on = i === active;\n            const reached = i <= active;\n            return (\n              <div\n                key={i}\n                ref={(el) => {\n                  stepRefs.current[i] = el;\n                }}\n                aria-current={on ? \"step\" : undefined}\n                className=\"relative min-h-[calc(var(--story-h)*0.42)] pl-7 @min-[520px]:min-h-[calc(var(--story-h)*0.55)]\"\n              >\n                <span\n                  ref={(el) => {\n                    dotRefs.current[i] = el;\n                  }}\n                  aria-hidden=\"true\"\n                  className=\"absolute left-0 top-[3px] size-[11px] rounded-full border\"\n                  style={{\n                    borderColor: reached ? \"var(--foreground)\" : \"var(--border)\",\n                    backgroundColor: reached ? \"var(--foreground)\" : \"var(--background)\",\n                    transform: on ? \"scale(1.15)\" : \"scale(1)\",\n                    transition: reduced ? \"none\" : `background-color 240ms ${EASE}, border-color 240ms ${EASE}, transform 320ms cubic-bezier(0.34,1.36,0.64,1)`,\n                  }}\n                />\n                <div style={{ opacity: on ? 1 : 0.35, transition: reduced ? \"none\" : `opacity 400ms ${EASE}` }}>\n                  <p className=\"text-[11.5px] leading-4 tabular-nums text-muted-foreground\">{pad(i + 1)}</p>\n                  <h3 className=\"mt-1.5 text-[18px] font-semibold leading-tight tracking-tight text-foreground @min-[520px]:text-[20px]\">\n                    <button type=\"button\" onClick={() => jump(i)} className=\"rounded-[4px] text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\">\n                      {s.title}\n                    </button>\n                  </h3>\n                  {s.body && <div className=\"mt-2 max-w-[36ch] text-[13.5px] leading-relaxed text-muted-foreground\">{s.body}</div>}\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/scroll-story"}