{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sticky-deck",
  "type": "registry:ui",
  "title": "Sticky Deck",
  "description": "Cards that pin one after another as you scroll. Each new card slides over the last while the deck behind it settles back a layer, scaling down and dimming, so the earlier cards' edges peek out above the front one. Click an edge to bring that card back.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "scroll",
    "cards",
    "sticky"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/sticky-deck",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/sticky-deck.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - A card's travel is native `position: sticky`; only its settling back is driven, by transform\n *   and opacity from one `position` motion value, so scrolling never re-renders a card.\n * - `position` is read from the deck's live rect on every scroll frame, so content shifting above\n *   the deck never leaves the cards out of step.\n * - Cards scale from their top edge (`origin-top`), so a buried card's edge peeks out above the\n *   one in front; the dimming is a black layer's opacity on `rounded-[inherit]`, not a filter.\n * - A buried card's edge (`data-buried`) is a handle: clicking it, or focusing inside a hidden\n *   card, scrolls the deck back to that card.\n * - Reduced motion drops the rise, scale and lift; cards stay sticky and still dim.\n */\nconst TIMING = {\n  /** The spring a buried card's edge rises and sinks on: its visual duration, in seconds. */\n  liftDuration: 0.3,\n  /** How much that spring overshoots, from 0 (none) up (the `liftBounce` prop). */\n  liftBounce: 0.25,\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * STICKY DECK STORYBOARD   (driven by scroll, not time)\n *\n *  arrive   a card scrolls up with the page, 1:1, and pins at\n *           `top`. Its travel is native `position: sticky`, so\n *           it never lags the finger or the wheel\n *  cover    the next card's top edge reaches this card's bottom\n *           edge, and this card starts settling back one layer\n *           in step with the next card's travel:\n *             rises `offsetStep`, so its top edge peeks out\n *             scales toward `scaleTo` from that top edge\n *             dims toward `dimTo` brightness\n *           it lands back there the moment the next card pins\n *  deeper   every card already in the deck settles one more\n *           layer at the same time; scale and dim compound\n *  past     beyond `depth` layers a card slides under the one\n *  depth    in front of it and fades out\n *  end      the last card pins and the whole deck holds for\n *           `hold`, then scrolls away together\n *\n *  pointer  over a buried card's edge: it lifts half a step.\n *           Click it and the deck scrolls back to that card\n *  keyboard focus inside a card scrolls the deck to it\n *  reduced  no rise, scale or lift; cards stay sticky and dim\n * ───────────────────────────────────────────────────────── */\n\nconst DECK = {\n  top: 96, // px from the top of the scrollport to the front card\n  offsetStep: 16, // px each buried card peeks out above the one in front\n  scaleTo: 0.94, // scale one layer back; compounds per layer\n  dimTo: 0.86, // brightness one layer back; compounds per layer\n  depth: 3, // layers that stay visible behind the front card\n  hold: \"20vh\", // scroll the finished deck stays pinned before it moves on\n  lift: 0.5, // share of offsetStep a buried card rises under the pointer\n  buried: 0.5, // layers of cover from which a card's edge is clickable\n};\n\n/** Lets you drive the deck from outside, e.g. a list of steps beside it. */\nexport interface StickyDeckApi {\n  /** Scrolls until this card is at the front of the deck. */\n  scrollTo: (index: number) => void;\n}\n\ntype Geometry = {\n  /** Each card's resting (unpinned) top edge, from the deck's border edge, in px. */\n  offsets: number[];\n  heights: number[];\n  /** The cards' `top`, resolved to px. */\n  top: number;\n};\n\ntype Deck = {\n  /**\n   * Where the deck is, in cards: -1 before the first card arrives, i when card\n   * i has just pinned, i + 0.5 when the next card covers half of it.\n   */\n  position: MotionValue<number>;\n  top: number | string;\n  offsetStep: number;\n  scaleTo: number;\n  dimTo: number;\n  depth: number;\n  lift: number;\n  liftBounce: number;\n  reduced: boolean;\n  register: (index: number, element: HTMLElement | null) => () => void;\n  scrollTo: (index: number) => void;\n};\n\ntype Card = {\n  index: number;\n  /** 0 → 1 as the card travels into place; 1 once it pins. */\n  progress: MotionValue<number>;\n  /** How many cards cover it: 0 at the front, 1 one layer back, fractional in between. */\n  depth: MotionValue<number>;\n};\n\nconst DeckContext = React.createContext<Deck | null>(null);\nconst IndexContext = React.createContext(-1);\nconst CardContext = React.createContext<Card | null>(null);\n\nfunction useDeck() {\n  const deck = React.useContext(DeckContext);\n  if (!deck) throw new Error(\"<StickyDeckCard> must be rendered inside <StickyDeck>.\");\n  return deck;\n}\n\n/* ── StickyDeck ─────────────────────────────────────────── */\n\nexport interface StickyDeckProps extends React.HTMLAttributes<HTMLDivElement> {\n  /**\n   * Where the front card pins, from the top of the scrollport. A number is in\n   * px; a string is any CSS length. Leave room above it for the edges that\n   * peek out: `depth * offsetStep`.\n   */\n  top?: number | string;\n  /** How far each buried card's top edge peeks out above the card in front, in px. */\n  offsetStep?: number;\n  /** Scale of the card one layer back. Each further layer multiplies it again. */\n  scaleTo?: number;\n  /** Brightness of the card one layer back, from 0 to 1. Each further layer multiplies it again. */\n  dimTo?: number;\n  /** How many cards stay visible behind the front one. Deeper cards fade out. */\n  depth?: number;\n  /** How far a buried card's edge rises under the pointer, as a share of `offsetStep`. */\n  lift?: number;\n  /** How much the edge overshoots as it rises and sinks back, from 0 (none) up. */\n  liftBounce?: number;\n  /**\n   * How much more scrolling the finished deck stays pinned for, after the last\n   * card lands and the deck's gap passes, before it moves on with the page. A\n   * number is in px; a string is any CSS length.\n   */\n  hold?: number | string;\n  /**\n   * Scroll container to track. Defaults to the window. Pass it when the deck\n   * scrolls inside an `overflow-y-auto` box.\n   */\n  containerRef?: React.RefObject<HTMLElement | null>;\n  /** Called with the index of the front card whenever it changes. */\n  onActiveChange?: (index: number) => void;\n  /** Receives `{ scrollTo(index) }`, to bring a card to the front from outside the deck. */\n  apiRef?: React.Ref<StickyDeckApi>;\n}\n\n/**\n * A column of cards that pin one after another as you scroll. Each new card\n * slides over the last while the deck behind it settles back a layer, so the\n * buried cards' top edges peek out above the front one. Give the cards the\n * same height.\n */\nexport function StickyDeck({\n  top = DECK.top,\n  offsetStep = DECK.offsetStep,\n  scaleTo = DECK.scaleTo,\n  dimTo = DECK.dimTo,\n  depth = DECK.depth,\n  lift = DECK.lift,\n  liftBounce = TIMING.liftBounce,\n  hold = DECK.hold,\n  containerRef,\n  onActiveChange,\n  apiRef,\n  className,\n  children,\n  ...props\n}: StickyDeckProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const cardsRef = React.useRef<(HTMLElement | null)[]>([]);\n  const geometryRef = React.useRef<Geometry>({ offsets: [], heights: [], top: 0 });\n  const observerRef = React.useRef<ResizeObserver | null>(null);\n  const reduced = useReducedMotion() ?? false;\n  const position = useMotionValue(-1);\n  const { scrollY } = useScroll({ container: containerRef });\n\n  const cards = React.Children.toArray(children);\n  const count = cards.length;\n\n  /** The top of the scrollport, in viewport px. Null until the container mounts. */\n  const viewTop = React.useCallback(() => {\n    if (!containerRef) return 0;\n    const container = containerRef.current;\n    return container ? container.getBoundingClientRect().top + container.clientTop : null;\n  }, [containerRef]);\n\n  // Reads the deck's live position, so content shifting above it never leaves\n  // the cards out of step. Called on every scroll frame: one or two rect reads.\n  const update = React.useCallback(() => {\n    const root = rootRef.current;\n    const view = viewTop();\n    const { offsets, heights, top } = geometryRef.current;\n    if (!root || view === null || !offsets.length) return;\n\n    const deckTop = root.getBoundingClientRect().top - view;\n    // Card i arrives over the distance of the card it covers: it starts when\n    // its top edge meets that card's bottom edge and ends when it pins.\n    let next = -1;\n    for (let i = 0; i < offsets.length; i++) {\n      const span = Math.max(heights[i - 1] ?? heights[i], 1);\n      next += clamp((top + span - (deckTop + offsets[i])) / span, 0, 1);\n    }\n    position.set(next);\n  }, [position, viewTop]);\n\n  // Resting positions come from the flex column's own arithmetic. A pinned\n  // card's rect (and offsetTop) already includes its sticky shift.\n  const measure = React.useCallback(() => {\n    const root = rootRef.current;\n    const elements = cardsRef.current.filter((element): element is HTMLElement => !!element);\n    if (!root || !elements.length) return;\n\n    const deckStyle = getComputedStyle(root);\n    const gap = px(deckStyle.rowGap);\n    let y = px(deckStyle.borderTopWidth) + px(deckStyle.paddingTop);\n    const offsets: number[] = [];\n    const heights: number[] = [];\n    elements.forEach((element, i) => {\n      const style = getComputedStyle(element);\n      y += (i ? gap : 0) + px(style.marginTop);\n      offsets.push(y);\n      heights.push(element.offsetHeight);\n      y += element.offsetHeight + px(style.marginBottom);\n    });\n    geometryRef.current = { offsets, heights, top: px(getComputedStyle(elements[0]).top) };\n    update();\n  }, [update]);\n\n  const register = React.useCallback(\n    (index: number, element: HTMLElement | null) => {\n      if (!element) return () => {};\n      cardsRef.current[index] = element;\n      // Observing reports the size once, which measures the deck again.\n      observerRef.current?.observe(element);\n      return () => {\n        if (cardsRef.current[index] === element) cardsRef.current[index] = null;\n        observerRef.current?.unobserve(element);\n      };\n    },\n    [],\n  );\n\n  const scrollTo = React.useCallback(\n    (index: number) => {\n      const root = rootRef.current;\n      const view = viewTop();\n      const { offsets, top } = geometryRef.current;\n      if (!root || view === null || offsets[index] === undefined) return;\n\n      // The card is at the front when its resting top edge sits at `top`.\n      const delta = root.getBoundingClientRect().top - view + offsets[index] - top;\n      if (Math.abs(delta) < 1) return;\n      const behavior = matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"instant\" : \"smooth\";\n      const container = containerRef?.current;\n      if (container) container.scrollTo({ top: container.scrollTop + delta, behavior });\n      else window.scrollTo({ top: window.scrollY + delta, behavior });\n    },\n    [containerRef, viewTop],\n  );\n\n  React.useImperativeHandle(apiRef, () => ({ scrollTo }), [scrollTo]);\n\n  // Passive effect: by now a parent's containerRef is attached too.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const observer = new ResizeObserver(() => measure());\n    observer.observe(root);\n    cardsRef.current.forEach((element) => element && observer.observe(element));\n    observerRef.current = observer;\n    window.addEventListener(\"resize\", measure);\n    measure();\n    return () => {\n      observer.disconnect();\n      observerRef.current = null;\n      window.removeEventListener(\"resize\", measure);\n    };\n  }, [measure]);\n\n  useMotionValueEvent(scrollY, \"change\", update);\n\n  const activeRef = React.useRef(0);\n  useMotionValueEvent(position, \"change\", (value) => {\n    const index = clamp(Math.round(value), 0, Math.max(count - 1, 0));\n    if (index === activeRef.current) return;\n    activeRef.current = index;\n    onActiveChange?.(index);\n  });\n\n  const deck = React.useMemo<Deck>(\n    () => ({ position, top, offsetStep, scaleTo, dimTo, depth, lift, liftBounce, reduced, register, scrollTo }),\n    [position, top, offsetStep, scaleTo, dimTo, depth, lift, liftBounce, reduced, register, scrollTo],\n  );\n\n  return (\n    <DeckContext.Provider value={deck}>\n      <div ref={rootRef} role=\"list\" className={cn(\"isolate flex flex-col gap-8\", className)} {...props}>\n        {cards.map((card, index) => (\n          <IndexContext.Provider key={React.isValidElement(card) ? card.key : index} value={index}>\n            {card}\n          </IndexContext.Provider>\n        ))}\n        {/* Sticky cards stay pinned until the deck's content box ends. Padding\n            doesn't extend it and a card's own margin counts against that card,\n            so the hold is a spacer. */}\n        {hold ? <div aria-hidden className=\"shrink-0\" style={{ height: hold }} /> : null}\n      </div>\n    </DeckContext.Provider>\n  );\n}\n\n/* ── StickyDeckCard ─────────────────────────────────────── */\n\n// The card is a motion.div, whose drag and animation handlers differ from the DOM's.\nexport type StickyDeckCardProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"\n>;\n\n/**\n * One card in the deck. Style it like any card: background, rounding, padding\n * and a fixed height. The dimming layer follows its rounded corners.\n */\nexport function StickyDeckCard({\n  className,\n  style,\n  children,\n  onClickCapture,\n  onFocus,\n  onPointerEnter,\n  onPointerMove,\n  onPointerLeave,\n  ...props\n}: StickyDeckCardProps) {\n  const deck = useDeck();\n  const index = React.useContext(IndexContext);\n  const ref = React.useRef<HTMLDivElement>(null);\n  const {\n    position,\n    top,\n    offsetStep,\n    scaleTo,\n    dimTo,\n    depth: layers,\n    lift: liftBy,\n    liftBounce,\n    reduced,\n    register,\n    scrollTo,\n  } = deck;\n\n  React.useLayoutEffect(() => register(index, ref.current), [register, index]);\n\n  const progress = useTransform(position, (p) => clamp(p - index + 1, 0, 1));\n  const depth = useTransform(position, (p) => Math.max(0, p - index));\n  // 0 → 1 while a mouse is over the card's peeking edge. Sinks back as the\n  // card comes to the front again.\n  const lift = useSpring(0, { visualDuration: TIMING.liftDuration, bounce: liftBounce });\n  // Only an edge the pointer meets while the card is buried lifts. A resting\n  // pointer that the deck scrolls under would otherwise nudge every card it\n  // passes.\n  const raise = (event: React.PointerEvent) => {\n    if (event.pointerType === \"mouse\" && depth.get() >= DECK.buried) lift.set(1);\n  };\n\n  const transform = useTransform([depth, lift], ([d, l]: number[]) => {\n    if (reduced || (d === 0 && l === 0)) return \"none\";\n    const rise = (Math.min(d, layers) + l * Math.min(d, 1) * liftBy) * offsetStep;\n    const scale = scaleTo ** Math.min(d, layers + 1);\n    return `translateY(${-rise}px) scale(${scale})`;\n  });\n  const opacity = useTransform(depth, (d) => clamp(layers + 1 - d, 0, 1));\n  const pointerEvents = useTransform(opacity, (o) => (o > 0 ? \"auto\" : \"none\"));\n  const shade = useTransform(depth, (d) => 1 - dimTo ** Math.min(d, layers + 1));\n\n  // A buried card's peeking edge is a handle back to it.\n  useMotionValueEvent(depth, \"change\", (d) => {\n    const element = ref.current;\n    const buried = d >= DECK.buried && d < layers + 1;\n    if (element && element.hasAttribute(\"data-buried\") !== buried) {\n      element.toggleAttribute(\"data-buried\", buried);\n    }\n  });\n\n  const card = React.useMemo<Card>(() => ({ index, progress, depth }), [index, progress, depth]);\n\n  return (\n    <CardContext.Provider value={card}>\n      <motion.div\n        ref={ref}\n        role=\"listitem\"\n        className={cn(\n          \"sticky origin-top will-change-transform data-buried:cursor-pointer\",\n          className,\n        )}\n        style={{ ...style, top, transform, opacity, pointerEvents }}\n        onClickCapture={(event) => {\n          // A real click (not Enter on a focused link) on a buried card brings it back.\n          if (event.detail > 0 && depth.get() >= DECK.buried) {\n            event.preventDefault();\n            event.stopPropagation();\n            scrollTo(index);\n          }\n          onClickCapture?.(event);\n        }}\n        onFocus={(event) => {\n          // Tabbing into a card the deck hides, or hasn't dealt yet, brings it forward.\n          if (event.target.matches(\":focus-visible\") && Math.abs(position.get() - index) > 0.02) {\n            scrollTo(index);\n          }\n          onFocus?.(event);\n        }}\n        onPointerEnter={(event) => {\n          raise(event);\n          onPointerEnter?.(event);\n        }}\n        onPointerMove={(event) => {\n          raise(event);\n          onPointerMove?.(event);\n        }}\n        onPointerLeave={(event) => {\n          lift.set(0);\n          onPointerLeave?.(event);\n        }}\n        {...props}\n      >\n        {children}\n        <motion.span\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 z-10 m-0 block rounded-[inherit] bg-black\"\n          style={{ opacity: shade }}\n        />\n      </motion.div>\n    </CardContext.Provider>\n  );\n}\n\n/* ── useStickyDeckCard ──────────────────────────────────── */\n\n/**\n * The card a component sits in, for effects inside it: `progress` runs 0 → 1\n * as the card travels into place, and `depth` counts the cards covering it.\n */\nexport function useStickyDeckCard() {\n  const card = React.useContext(CardContext);\n  if (!card) throw new Error(\"useStickyDeckCard() must be called inside <StickyDeckCard>.\");\n  return card;\n}\n\n/* ── helpers ────────────────────────────────────────────── */\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction px(value: string) {\n  return parseFloat(value) || 0;\n}\n"
    }
  ]
}