{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stacking-cards",
  "type": "registry:ui",
  "title": "Stacking Cards",
  "description": "Cards that stick and stack as you scroll, the ones behind shrinking and dimming.",
  "categories": [
    "scroll"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json"
  ],
  "files": [
    {
      "path": "components/ui/stacking-cards.tsx",
      "content": "\"use client\";\nimport { useReducedMotion } from \"motion/react\";\nimport { Children, createContext, use, useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\ntype Ctx = { index: number; top: number; peek: number };\nconst CardContext = createContext<Ctx | null>(null);\n\nexport type StackingCardsProps = React.ComponentProps<\"ol\"> & {\n  /** The scroll container. Leave it out when the page itself scrolls. */\n  root?: React.RefObject<HTMLElement | null>;\n  /** Where the first card sticks, in px from the top of the scroll area. */\n  top?: number;\n  /** How much of each card behind shows above the next one, in px. */\n  peek?: number;\n  /** How much each card shrinks per card stacked on it. */\n  scaleStep?: number;\n  /** How much each card dims per card stacked on it (0–1). */\n  dimStep?: number;\n  /** Past this many cards on top, a card stops shrinking and dimming. */\n  maxDepth?: number;\n};\n\n/**\n * Cards that stick as they reach the top and stack, the ones behind shrinking\n * and dimming as each new card slides over them. Tied to the scroll itself:\n * scroll back and the stack comes apart the same way.\n */\nexport function StackingCards({ root, top = 16, peek = 12, scaleStep = 0.045, dimStep = 0.14, maxDepth = 3, className, children, ...rest }: StackingCardsProps) {\n  const reduce = useReducedMotion();\n  const list = useRef<HTMLOListElement>(null);\n  const opts = useRef({ top, peek, scaleStep, dimStep, maxDepth, reduce });\n  useEffect(() => {\n    opts.current = { top, peek, scaleStep, dimStep, maxDepth, reduce };\n  });\n\n  useEffect(() => {\n    const ol = list.current;\n    if (!ol) return;\n    const scroller = root?.current ?? null;\n    const target: HTMLElement | Window = scroller ?? window;\n    const scrollTop = () => (scroller ? scroller.scrollTop : window.scrollY);\n    const cards = () => Array.from(ol.children) as HTMLElement[];\n\n    // Where each card sits in the flow, measured once per resize rather than per frame.\n    let natural: number[] = [];\n    let heights: number[] = [];\n    let viewport = 0;\n    const measure = () => {\n      const els = cards();\n      const rootTop = scroller ? scroller.getBoundingClientRect().top : 0;\n      const gap = parseFloat(getComputedStyle(ol).rowGap) || 0;\n      let y = ol.getBoundingClientRect().top - rootTop + scrollTop();\n      natural = [];\n      heights = els.map((el) => el.offsetHeight);\n      els.forEach((_, i) => {\n        natural.push(y);\n        y += heights[i] + gap;\n      });\n      viewport = scroller ? scroller.clientHeight : window.innerHeight;\n      paint();\n    };\n\n    const paint = () => {\n      const { top: t, peek: pk, scaleStep: ss, dimStep: ds, maxDepth: md, reduce: r } = opts.current;\n      const y = scrollTop();\n      const els = cards();\n      // How far each card has slid over the one before it: 0 below, 1 stuck on top.\n      const arrived = natural.map((n, j) => {\n        if (j === 0) return 0;\n        const stick = n - (t + j * pk);\n        const travel = Math.max(1, Math.min(heights[j - 1] ?? 1, viewport * 0.6));\n        return Math.max(0, Math.min(1, 1 - (stick - y) / travel));\n      });\n      let depth = 0;\n      for (let i = els.length - 1; i >= 0; i--) {\n        const d = Math.min(md, depth);\n        els[i].style.transform = r || d === 0 ? \"\" : `scale(${1 - d * ss})`;\n        els[i].style.setProperty(\"--stack-dim\", String(Math.min(0.6, d * ds)));\n        els[i].toggleAttribute(\"data-covered\", d > 0.5);\n        depth += arrived[i] ?? 0;\n      }\n    };\n\n    let frame = 0;\n    const onScroll = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(paint);\n    };\n    const ro = new ResizeObserver(() => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(measure);\n    });\n    ro.observe(ol);\n    cards().forEach((c) => ro.observe(c));\n    if (scroller) ro.observe(scroller);\n    target.addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"resize\", onScroll);\n    measure();\n\n    // Tabbing into a card that sits under others brings it back on top.\n    const onFocusIn = (e: FocusEvent) => {\n      const card = (e.target as HTMLElement).closest<HTMLElement>(\"[data-stack-card]\");\n      if (!card?.hasAttribute(\"data-covered\")) return;\n      const i = cards().indexOf(card);\n      const to = natural[i] - (opts.current.top + i * opts.current.peek);\n      if (scroller) scroller.scrollTop = to;\n      else window.scrollTo({ top: to });\n    };\n    ol.addEventListener(\"focusin\", onFocusIn);\n\n    return () => {\n      cancelAnimationFrame(frame);\n      ro.disconnect();\n      target.removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"resize\", onScroll);\n      ol.removeEventListener(\"focusin\", onFocusIn);\n    };\n  }, [root, reduce]);\n\n  return (\n    <ol ref={list} className={cn(\"flex flex-col gap-4\", className)} {...rest}>\n      {Children.toArray(children).map((child, index) => (\n        <CardContext key={(child as { key?: React.Key }).key ?? index} value={{ index, top, peek }}>\n          {child}\n        </CardContext>\n      ))}\n    </ol>\n  );\n}\n\nexport type StackingCardProps = React.ComponentProps<\"li\">;\n\n/** One card. Style the surface with className; it keeps its radius when dimmed. */\nexport function StackingCard({ className, style, children, ...rest }: StackingCardProps) {\n  const ctx = use(CardContext);\n  if (!ctx) throw new Error(\"<StackingCard> must be inside <StackingCards>\");\n  return (\n    <li\n      data-stack-card=\"\"\n      className={cn(\n        \"sticky origin-top rounded-2xl border border-line-2 bg-raised shadow-pop will-change-transform [--stack-dim:0]\",\n        // The dim is a wash of the page color over the card, so it reads as receding in both themes.\n        \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-page after:opacity-(--stack-dim) after:content-['']\",\n        className,\n      )}\n      style={{ top: ctx.top + ctx.index * ctx.peek, ...style }}\n      {...rest}\n    >\n      {children}\n    </li>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/stacking-cards"
}
