{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-stack",
  "title": "Scroll stack",
  "description": "Stack any React content as you scroll. Successive items scale into place while earlier ones fade. Page or contained scrolling, keyboard access, and a plain-list fallback.",
  "registryDependencies": [
    "@jbm/tokens"
  ],
  "files": [
    {
      "path": "registry/jbm/ui/scroll-stack.tsx",
      "content": "\"use client\"\n\nimport {\n  Children,\n  Fragment,\n  useEffect,\n  useRef,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\"\nimport { font } from \"../lib/tokens\"\n\nexport type ScrollStackProps = {\n  /** Each direct child is one stack item. Its content and styling stay yours. */\n  children: ReactNode\n  /** Omit to use page scrolling; set pixels for a self-contained viewport. */\n  height?: number\n  /** Space between successive items, in pixels. */\n  distance?: number\n  /** Sticky inset from the viewport top, in pixels. */\n  top?: number\n  /** Scale of the outgoing item, clamped to 0.5–1. */\n  minScale?: number\n  /** Show a regular list. Defaults to the system reduced-motion preference. */\n  reducedMotion?: boolean\n  label?: string\n  className?: string\n  style?: CSSProperties\n}\n\n/** Native scroll and sticky positioning; arbitrary children, no scroll interception. */\nexport function ScrollStack({\n  children,\n  height,\n  distance = 120,\n  top = 24,\n  minScale = 0.9,\n  reducedMotion,\n  label = \"Scroll through the stack\",\n  className,\n  style,\n}: ScrollStackProps) {\n  const root = useRef<HTMLDivElement>(null)\n  const list = useRef<HTMLDivElement>(null)\n  const tail = useRef<HTMLDivElement>(null)\n  const items = Children.toArray(children)\n  const contained = height !== undefined\n  const viewportHeight =\n    height !== undefined && Number.isFinite(height)\n      ? Math.max(160, height)\n      : 480\n  const inset = Number.isFinite(top) ? Math.max(0, top) : 24\n  const gap = Number.isFinite(distance) ? Math.max(0, distance) : 120\n  const scale = Number.isFinite(minScale)\n    ? Math.min(1, Math.max(0.5, minScale))\n    : 0.9\n\n  useEffect(() => {\n    const container = root.current\n    const content = list.current\n    if (!container || !content) return\n    // Direct children only: a nested ScrollStack owns its own elements.\n    const rows = Array.from(content.children).filter(\n      (element): element is HTMLDivElement =>\n        element instanceof HTMLDivElement &&\n        element.dataset.stackItem !== undefined\n    )\n    const anchors = rows.map((row) => row.previousElementSibling as HTMLElement)\n    const surfaces = rows.map((row) => row.firstElementChild as HTMLElement)\n    const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const scroller = contained ? container : window\n    let frame = 0\n    let animated = false\n    let viewport = 0\n\n    function update() {\n      frame = 0\n      if (!animated) return\n      const origin = contained\n        ? container!.getBoundingClientRect().top + container!.clientTop\n        : 0\n      const pin = origin + inset\n      const positions = anchors.map(\n        (anchor) => anchor.getBoundingClientRect().top\n      )\n      const progress = (position: number, previous: number) => {\n        const remaining = position - pin\n        const range = Math.max(\n          1,\n          Math.min(viewport, rows[previous].offsetHeight + gap)\n        )\n        return remaining <= 1\n          ? 1\n          : Math.min(1, Math.max(0, 1 - remaining / range))\n      }\n      surfaces.forEach((surface, index) => {\n        const incoming = index === 0 ? 1 : progress(positions[index], index - 1)\n        const outgoing =\n          index === rows.length - 1 ? 0 : progress(positions[index + 1], index)\n        const fade = Math.min(1, Math.max(0, (outgoing - 0.65) / 0.35))\n        surface.style.transform = `scale(${1 + (1 - incoming) * 0.06 - outgoing * (1 - scale)})`\n        surface.style.opacity = String(1 - fade)\n        surface.style.pointerEvents = fade >= 1 ? \"none\" : \"auto\"\n      })\n    }\n    function schedule() {\n      if (!frame) frame = requestAnimationFrame(update)\n    }\n    function measure() {\n      viewport = contained ? container!.clientHeight : window.innerHeight\n      animated =\n        !(reducedMotion ?? media.matches) &&\n        rows.length > 1 &&\n        rows.every((row) => row.offsetHeight <= viewport - inset * 2)\n      container!.dataset.stackMode = animated ? \"stack\" : \"list\"\n      content!.style.paddingTop = `${inset}px`\n      // Sticky constraints exclude parent padding: keep the final resting space inside the content box.\n      if (tail.current)\n        tail.current.style.height = `${animated ? Math.max(0, viewport - (rows.at(-1)?.offsetHeight ?? 0) - inset * 2) : 0}px`\n      rows.forEach((row, index) => {\n        row.style.position = animated ? \"sticky\" : \"relative\"\n        row.style.top = animated ? `${inset}px` : \"auto\"\n        row.style.marginBottom =\n          index < rows.length - 1 ? `${animated ? gap : 24}px` : \"0\"\n        surfaces[index].style.transform = \"none\"\n        surfaces[index].style.opacity = \"1\"\n        surfaces[index].style.pointerEvents = \"auto\"\n      })\n      schedule()\n    }\n    function reveal(event: FocusEvent) {\n      if (!animated) return\n      const index = rows.findIndex((row) => row.contains(event.target as Node))\n      if (index < 0) return\n      const origin = contained\n        ? container!.getBoundingClientRect().top + container!.clientTop\n        : 0\n      const delta = anchors[index].getBoundingClientRect().top - origin - inset\n      // Keyboard focus can reach a previously covered item. Reveal its original position.\n      if (Math.abs(delta) > 1)\n        scroller.scrollBy({ top: delta, behavior: \"instant\" })\n      update()\n    }\n    const observer = new ResizeObserver(measure)\n    observer.observe(container)\n    rows.forEach((row) => observer.observe(row))\n    scroller.addEventListener(\"scroll\", schedule, { passive: true })\n    window.addEventListener(\"resize\", measure)\n    media.addEventListener(\"change\", measure)\n    container.addEventListener(\"focusin\", reveal)\n    measure()\n    return () => {\n      observer.disconnect()\n      cancelAnimationFrame(frame)\n      scroller.removeEventListener(\"scroll\", schedule)\n      window.removeEventListener(\"resize\", measure)\n      media.removeEventListener(\"change\", measure)\n      container.removeEventListener(\"focusin\", reveal)\n    }\n  }, [children, contained, viewportHeight, inset, gap, scale, reducedMotion])\n\n  return (\n    <div\n      ref={root}\n      role=\"region\"\n      aria-label={label}\n      tabIndex={contained && items.length ? 0 : undefined}\n      className={className}\n      style={{\n        width: \"100%\",\n        fontFamily: font.sans,\n        ...style,\n        height: contained ? viewportHeight : undefined,\n        overflowY: contained ? \"auto\" : undefined,\n        overscrollBehaviorY: contained ? \"contain\" : undefined,\n      }}\n    >\n      <div\n        ref={list}\n        role=\"list\"\n        style={{\n          position: \"relative\",\n          padding: `${inset}px 24px`,\n          isolation: \"isolate\",\n        }}\n      >\n        {items.map((child, index) => (\n          <Fragment\n            key={\n              typeof child === \"object\" && \"key\" in child ? child.key : index\n            }\n          >\n            <div aria-hidden=\"true\" />\n            <div\n              role=\"listitem\"\n              data-stack-item=\"\"\n              style={{\n                position: \"relative\",\n                zIndex: index + 1,\n                marginBottom: index < items.length - 1 ? 24 : 0,\n              }}\n            >\n              <div\n                style={{ transformOrigin: \"center top\", display: \"flow-root\" }}\n              >\n                {child}\n              </div>\n            </div>\n          </Fragment>\n        ))}\n        <div ref={tail} aria-hidden=\"true\" />\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "src/jbm/ui/scroll-stack.tsx"
    }
  ],
  "type": "registry:ui"
}