{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-stack",
  "type": "registry:ui",
  "title": "Card Stack",
  "description": "Cards that pin one behind another as the page scrolls, each settling behind the next.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/loomui/card-stack.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface CardStackProps extends React.ComponentProps<\"div\"> {\n  /** Pixels from the top of the viewport where the first card pins. */\n  offset?: number\n  /** Pixels of the card below that stay visible once it is covered. */\n  peek?: number\n  /**\n   * How much a fully covered card shrinks, as a fraction of its size. Off by\n   * default: it only reads as depth when `peek` is large enough to show a real\n   * strip of the card behind.\n   */\n  scale?: number\n  /** How much a fully covered card fades, from 0 to 1. */\n  dim?: number\n  /** Pixels of scroll the last card stays pinned for once it lands. */\n  tail?: number\n  /** Pin the cards but leave them at full size and opacity. */\n  disabled?: boolean\n}\n\n/** Smoothstep. Eases the start and the end of the burial. */\nfunction smooth(t: number) {\n  return t * t * (3 - 2 * t)\n}\n\n/**\n * Sticky positions against the nearest scrolling ancestor, not always the page,\n * so the measurements have to be taken against that same box.\n */\nfunction findScrollport(node: HTMLElement) {\n  let parent = node.parentElement\n  while (parent) {\n    const overflow = getComputedStyle(parent).overflowY\n    if (overflow === \"auto\" || overflow === \"scroll\") {\n      return parent\n    }\n    parent = parent.parentElement\n  }\n  return null\n}\n\n/**\n * Each card pins a little lower than the last, so a covered one keeps a strip on\n * screen. Progress is measured from the card *after* it: a pinned card stops\n * moving, so only what is burying it knows how buried it is.\n */\nexport function CardStack({\n  children,\n  className,\n  offset = 96,\n  peek = 14,\n  scale = 0,\n  dim = 0.12,\n  tail = 200,\n  disabled = false,\n  style,\n  ...props\n}: CardStackProps) {\n  const ref = React.useRef<HTMLDivElement>(null)\n\n  React.useEffect(() => {\n    const root = ref.current\n    if (!root) {\n      return\n    }\n\n    const items = () =>\n      Array.from(\n        root.querySelectorAll<HTMLElement>(\"[data-slot='card-stack-item']\")\n      )\n\n    // The pin position is per card and never changes, so it is written once\n    // rather than recomputed on every frame of every scroll.\n    for (const [index, item] of items().entries()) {\n      item.style.setProperty(\"--card-top\", `${offset + index * peek}px`)\n    }\n\n    if (\n      disabled ||\n      window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n    ) {\n      for (const item of items()) {\n        item.style.setProperty(\"--card-progress\", \"0\")\n      }\n      return\n    }\n\n    const port = findScrollport(root)\n    let frame = 0\n\n    const measure = () => {\n      const list = items()\n      const portTop = port ? port.getBoundingClientRect().top : 0\n      const portHeight = port ? port.clientHeight : window.innerHeight\n\n      for (const [index, item] of list.entries()) {\n        const next = list[index + 1]\n        if (!next) {\n          item.style.setProperty(\"--card-progress\", \"0\")\n          continue\n        }\n\n        // Where the next card starts covering this one, and where it finishes.\n        const end = offset + (index + 1) * peek\n        const travelled =\n          portHeight - (next.getBoundingClientRect().top - portTop)\n        const total = portHeight - end\n        const progress = total > 0 ? travelled / total : 0\n\n        item.style.setProperty(\n          \"--card-progress\",\n          `${smooth(Math.min(Math.max(progress, 0), 1))}`\n        )\n      }\n    }\n\n    const onScroll = () => {\n      cancelAnimationFrame(frame)\n      frame = requestAnimationFrame(measure)\n    }\n\n    const target: HTMLElement | Window = port ?? window\n    measure()\n    target.addEventListener(\"scroll\", onScroll, { passive: true })\n    window.addEventListener(\"resize\", onScroll, { passive: true })\n    return () => {\n      target.removeEventListener(\"scroll\", onScroll)\n      window.removeEventListener(\"resize\", onScroll)\n      cancelAnimationFrame(frame)\n    }\n  }, [offset, peek, disabled])\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"card-stack\"\n      className={cn(\"relative\", className)}\n      style={\n        {\n          \"--card-scale\": `${scale}`,\n          \"--card-dim\": `${dim}`,\n          ...style,\n        } as React.CSSProperties\n      }\n      {...props}\n    >\n      {children}\n\n      {/* Room for the last card to hold its pin in. It has to be an element in\n          the flow rather than padding on this wrapper: a sticky box is held\n          inside its containing block's content box, so padding here would buy\n          the scroll distance and then drag every pinned card up out of the\n          stack at the end of it. */}\n      {tail > 0 ? <div aria-hidden=\"true\" style={{ height: tail }} /> : null}\n    </div>\n  )\n}\n\nexport type CardStackItemProps = React.ComponentProps<\"div\">\n\nexport function CardStackItem({\n  children,\n  className,\n  style,\n  ...props\n}: CardStackItemProps) {\n  return (\n    <div\n      data-slot=\"card-stack-item\"\n      className={cn(\"sticky\", className)}\n      style={\n        {\n          top: \"var(--card-top, 0px)\",\n          transformOrigin: \"top center\",\n          scale: \"calc(1 - var(--card-progress, 0) * var(--card-scale, 0))\",\n          opacity: \"calc(1 - var(--card-progress, 0) * var(--card-dim, 0))\",\n          willChange: \"scale, opacity\",\n          ...style,\n        } as React.CSSProperties\n      }\n      {...props}\n    >\n      {children}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ]
}