{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sticky-stack","type":"registry:component","title":"Sticky stack","description":"Cards pile up and sink back as you scroll.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/sticky-stack.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type StackItem = { title: string; text: string };\n\n// Layout is fixed in px so every scroll position can be computed instead of\n// measured: sticky elements report their stuck position, not their natural\n// one, so measuring them mid-scroll would be wrong anyway.\nconst VIEWPORT = 480;\nconst HEADER = 112;\nconst CARD = 280;\n// Scroll distance between one card settling and the next one arriving.\nconst GAP = 80;\nconst FIRST_TOP = 20;\n// Each stuck card sits this much lower, leaving a strip of every card\n// beneath it visible.\nconst STEP = 14;\n// Per card resting on top. Four cards deep this reaches 0.84, still wide\n// enough that the stack reads as cards rather than a fan.\nconst SCALE_PER_CARD = 0.04;\nconst DIM_PER_CARD = 0.12;\nconst MAX_DIM = 0.4;\n\nconst top = (i: number) => FIRST_TOP + i * STEP;\nconst natural = (i: number) => HEADER + i * (CARD + GAP);\n\nexport function StickyStack({\n  items,\n  label,\n  heading,\n  className,\n}: {\n  items: StackItem[];\n  label: string;\n  heading?: React.ReactNode;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const scroller = useRef<HTMLDivElement>(null);\n  const cards = useRef<(HTMLElement | null)[]>([]);\n  const shades = useRef<(HTMLElement | null)[]>([]);\n  const fills = useRef<(HTMLElement | null)[]>([]);\n  const dots = useRef<(HTMLElement | null)[]>([]);\n\n  const last = items.length - 1;\n  // Just enough room below the last card for it to reach its stuck spot, so\n  // the scroll ends exactly as the stack completes.\n  const bottomPad = VIEWPORT - CARD - top(last);\n\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    let frame = 0;\n    let current = -1;\n\n    // One read (scrollTop) and a batch of transform and opacity writes per\n    // frame, nothing that triggers layout.\n    const update = () => {\n      frame = 0;\n      const s = el.scrollTop;\n      // How far card i + 1 has slid over card i, 0 to 1: from its top edge\n      // meeting card i's bottom edge until it sticks.\n      const covered = items.map((_, i) => {\n        if (i === last) return 0;\n        const start = natural(i + 1) - top(i) - CARD;\n        const end = natural(i + 1) - top(i + 1);\n        return Math.min(Math.max((s - start) / (end - start), 0), 1);\n      });\n\n      let depth = 0;\n      for (let i = last; i >= 0; i--) {\n        depth += covered[i];\n        const card = cards.current[i];\n        const shade = shades.current[i];\n        if (card) {\n          card.style.transform = reduceMotion\n            ? \"\"\n            : `scale(${1 - depth * SCALE_PER_CARD})`;\n        }\n        if (shade) {\n          shade.style.opacity = reduceMotion\n            ? \"0\"\n            : String(Math.min(depth * DIM_PER_CARD, MAX_DIM));\n        }\n        const fill = fills.current[i];\n        if (fill) {\n          const arrived = i === 0 ? 1 : covered[i - 1];\n          fill.style.transform = `scaleY(${arrived})`;\n        }\n      }\n\n      const front = covered.filter((c) => c >= 0.5).length;\n      if (front !== current) {\n        dots.current[current]?.removeAttribute(\"aria-current\");\n        dots.current[front]?.setAttribute(\"aria-current\", \"step\");\n        current = front;\n      }\n    };\n\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(update);\n    };\n\n    update();\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => {\n      el.removeEventListener(\"scroll\", onScroll);\n      cancelAnimationFrame(frame);\n    };\n  }, [items, last, reduceMotion]);\n\n  const goTo = (i: number) => {\n    scroller.current?.scrollTo({\n      top: i === 0 ? 0 : natural(i) - top(i),\n      behavior: reduceMotion ? \"auto\" : \"smooth\",\n    });\n  };\n\n  return (\n    <div className={cn(\"flex w-[min(480px,100%)] gap-2\", className)}>\n      <div\n        ref={scroller}\n        role=\"region\"\n        aria-label={label}\n        tabIndex={0}\n        className=\"min-w-0 flex-1 overflow-y-auto overscroll-contain rounded-3xl bg-muted px-4 outline-hidden [scrollbar-width:none] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground [&::-webkit-scrollbar]:hidden\"\n        style={{ height: VIEWPORT }}\n      >\n        <div style={{ paddingBottom: bottomPad }}>\n          <div\n            className=\"flex flex-col justify-center gap-1 px-2\"\n            style={{ height: HEADER }}\n          >\n            {heading}\n          </div>\n          {items.map((item, i) => (\n            <article\n              key={item.title}\n              ref={(node) => {\n                cards.current[i] = node;\n              }}\n              className=\"sticky flex origin-top flex-col justify-between rounded-2xl bg-background p-6 shadow-raised will-change-transform\"\n              style={{\n                top: top(i),\n                height: CARD,\n                marginTop: i === 0 ? 0 : GAP,\n              }}\n            >\n              <span className=\"font-mono text-sm text-muted-foreground tabular-nums\">\n                {String(i + 1).padStart(2, \"0\")}\n              </span>\n              <div className=\"flex flex-col gap-2\">\n                <h3 className=\"text-2xl font-semibold tracking-tight text-balance\">\n                  {item.title}\n                </h3>\n                <p className=\"text-[15px] leading-relaxed text-pretty text-muted-foreground\">\n                  {item.text}\n                </p>\n              </div>\n              <div\n                ref={(node) => {\n                  shades.current[i] = node;\n                }}\n                aria-hidden\n                className=\"pointer-events-none absolute inset-0 rounded-[inherit] bg-black opacity-0\"\n              />\n            </article>\n          ))}\n        </div>\n      </div>\n      <nav aria-label=\"Jump to card\" className=\"flex flex-col justify-center\">\n        {items.map((item, i) => (\n          <button\n            key={item.title}\n            ref={(node) => {\n              dots.current[i] = node;\n            }}\n            type=\"button\"\n            aria-label={`${i + 1}. ${item.title}`}\n            onClick={() => goTo(i)}\n            className=\"group flex h-10 w-6 touch-manipulation items-center justify-center rounded-full outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-none\"\n          >\n            <span className=\"h-7 w-[3px] overflow-hidden rounded-full bg-border transition-[scale] duration-150 ease-out group-hover:scale-x-150 motion-reduce:transition-none\">\n              <span\n                ref={(node) => {\n                  fills.current[i] = node;\n                }}\n                className=\"block size-full origin-top bg-foreground\"\n                // Matches scroll position 0 before the effect first runs.\n                style={{ transform: `scaleY(${i === 0 ? 1 : 0})` }}\n              />\n            </span>\n          </button>\n        ))}\n      </nav>\n    </div>\n  );\n}\n\nconst PRINCIPLES: StackItem[] = [\n  {\n    title: \"Respond on press\",\n    text: \"Feedback starts the moment a finger lands, not when it lifts.\",\n  },\n  {\n    title: \"Stay interruptible\",\n    text: \"Anything in motion can be grabbed mid flight and sent somewhere else.\",\n  },\n  {\n    title: \"Keep the velocity\",\n    text: \"A release carries on at exactly the speed the hand gave it.\",\n  },\n  {\n    title: \"Leave the way you came\",\n    text: \"What slides in from the right goes back out to the right.\",\n  },\n  {\n    title: \"Show restraint\",\n    text: \"Motion people see a hundred times a day should barely be there.\",\n  },\n];\n\nexport default function StickyStackDemo() {\n  return (\n    <StickyStack\n      items={PRINCIPLES}\n      label=\"Five principles of motion\"\n      heading={\n        <>\n          <h2 className=\"text-xl font-semibold tracking-tight\">\n            Five principles of motion\n          </h2>\n          <p className=\"text-sm text-muted-foreground\">Scroll to stack them up.</p>\n        </>\n      }\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/sticky-stack"}