{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-stack",
  "type": "registry:ui",
  "title": "Scroll Stack",
  "description": "Cards that pin and pile up as you scroll, with covered cards easing back in scale and light.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/scroll-stack.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useScroll } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface ScrollStackItem {\n  /** Stable key for the card. */\n  id: string;\n  /** Card content. */\n  content: React.ReactNode;\n}\n\nexport interface ScrollStackProps extends Omit<React.OlHTMLAttributes<HTMLOListElement>, \"children\"> {\n  /** Cards to stack. Alternatively pass each card as a child. */\n  items?: ScrollStackItem[];\n  /** Each child becomes one card (used when `items` is not given). */\n  children?: React.ReactNode;\n  /** Scrollable ancestor that the stack scrolls inside. Defaults to the window. */\n  container?: React.RefObject<HTMLElement | null>;\n  /** Distance from the top of the scroll area where the first card pins, in px. */\n  top?: number;\n  /** Extra top offset per card, so earlier cards peek out above later ones, in px. */\n  stackOffset?: number;\n  /** How much a card shrinks for each card that lands on top of it (0.04 = 4%). */\n  scaleStep?: number;\n  /** Darken cards as they are covered. */\n  dim?: boolean;\n  /** Space between cards before they stack, in px. */\n  gap?: number;\n  /** Corner radius of the card shells, in px. */\n  radius?: number;\n  /** Show a small \"01 / 05\" counter on each card. */\n  showIndex?: boolean;\n  /** Accent used by the counter marker. */\n  accent?: string;\n  /** Classes added to every card shell. */\n  cardClassName?: string;\n}\n\nconst clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);\n\n/**\n * Cards pin with `position: sticky` at incrementally larger top offsets, so they pile up as the\n * page (or a scrollable container) scrolls. Covered cards scale down and dim in proportion to how\n * far the following cards have travelled. Without JavaScript it is still a plain sticky stack;\n * the scale/dim effect is layered on top through CSS variables written outside React renders.\n */\nexport function ScrollStack({\n  items,\n  children,\n  container,\n  top = 16,\n  stackOffset = 16,\n  scaleStep = 0.04,\n  dim = true,\n  gap = 32,\n  radius = 20,\n  showIndex = true,\n  accent = \"#FF7448\",\n  cardClassName = \"\",\n  className = \"\",\n  style,\n  ...props\n}: ScrollStackProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const listRef = React.useRef<HTMLOListElement>(null);\n  const cardRefs = React.useRef<(HTMLLIElement | null)[]>([]);\n  const { scrollY } = useScroll(container ? { container } : undefined);\n\n  const cards: { key: React.Key; content: React.ReactNode }[] = items\n    ? items.map((item) => ({ key: item.id, content: item.content }))\n    : React.Children.toArray(children).map((child, i) => ({\n        key: React.isValidElement(child) && child.key != null ? child.key : i,\n        content: child,\n      }));\n  const count = cards.length;\n\n  React.useEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n    const scroller = container?.current ?? null;\n    const els = cardRefs.current.slice(0, count);\n\n    // Natural (un-stuck) geometry, measured from layout rather than from the sticky elements'\n    // live positions: the list's top inside the scroll area and every card's height.\n    let listTop = 0;\n    let heights: number[] = [];\n\n    const measure = () => {\n      const rect = list.getBoundingClientRect();\n      // Compensate for CSS transforms on ancestors (e.g. a zoomed preview stage).\n      const ratio = list.offsetHeight ? rect.height / list.offsetHeight || 1 : 1;\n      if (scroller) {\n        const box = scroller.getBoundingClientRect();\n        listTop = (rect.top - box.top) / ratio - scroller.clientTop + scroller.scrollTop;\n      } else {\n        listTop = rect.top / ratio + window.scrollY;\n      }\n      heights = els.map((el) => el?.offsetHeight ?? 0);\n    };\n\n    const update = () => {\n      const y = scrollY.get();\n      // arrival[j]: 0 → 1 while card j travels from just below card j-1 to its pinned slot.\n      const arrival: number[] = new Array(count).fill(0);\n      let natural = 0;\n      for (let j = 0; j < count; j++) {\n        if (j > 0) {\n          natural += heights[j - 1] + gap;\n          const pinnedAt = listTop + natural - (top + j * stackOffset);\n          const travel = Math.max(1, heights[j - 1] + gap - stackOffset);\n          arrival[j] = clamp01((y - (pinnedAt - travel)) / travel);\n        }\n      }\n      let covered = 0;\n      for (let i = count - 1; i >= 0; i--) {\n        const el = els[i];\n        if (el) el.style.setProperty(\"--ss-depth\", reduceMotion ? \"0\" : covered.toFixed(4));\n        covered += arrival[i];\n      }\n    };\n\n    measure();\n    update();\n    const unsubscribe = scrollY.on(\"change\", update);\n    const ro = new ResizeObserver(() => {\n      measure();\n      update();\n    });\n    ro.observe(list);\n    if (scroller) ro.observe(scroller);\n    window.addEventListener(\"resize\", measure);\n    return () => {\n      unsubscribe();\n      ro.disconnect();\n      window.removeEventListener(\"resize\", measure);\n      for (const el of els) el?.style.removeProperty(\"--ss-depth\");\n    };\n  }, [container, scrollY, count, top, stackOffset, gap, reduceMotion]);\n\n  return (\n    <ol\n      ref={listRef}\n      className={`flex flex-col ${className}`}\n      style={{\n        gap,\n        [\"--ss-step\" as string]: reduceMotion ? 0 : scaleStep,\n        [\"--ss-accent\" as string]: accent,\n        ...style,\n      }}\n      {...props}\n    >\n      {cards.map((card, i) => (\n        <li\n          key={card.key}\n          ref={(node) => {\n            cardRefs.current[i] = node;\n          }}\n          className=\"sticky\"\n          style={{ top: top + i * stackOffset }}\n        >\n          <div\n            className={`relative origin-top overflow-hidden border border-zinc-900/10 bg-white text-zinc-900 shadow-[0_-10px_30px_-18px_rgba(0,0,0,0.28),0_1px_2px_rgba(0,0,0,0.04)] [scale:calc(1_-_var(--ss-depth,0)_*_var(--ss-step,0))] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:shadow-[0_-10px_30px_-18px_rgba(0,0,0,0.9)] ${cardClassName}`}\n            style={{ borderRadius: radius }}\n          >\n            {showIndex && (\n              <span\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute right-4 top-4 z-10 flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums text-zinc-500 dark:text-zinc-400\"\n              >\n                <span className=\"h-1.5 w-1.5 rounded-full bg-[var(--ss-accent)]\" />\n                {String(i + 1).padStart(2, \"0\")} / {String(count).padStart(2, \"0\")}\n              </span>\n            )}\n            {card.content}\n            {dim && (\n              // Covered cards darken; capped so their top edge stays legible.\n              <span\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute inset-0 bg-zinc-950 [opacity:min(calc(var(--ss-depth,0)_*_0.16),0.5)] dark:bg-black\"\n              />\n            )}\n          </div>\n        </li>\n      ))}\n    </ol>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/scroll-stack"
}
