{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "deck-lift",
  "title": "Deck Lift",
  "description": "A screen whose resting card deck lifts to the top, drops the sheet away, and pulls a full page of detail up behind it, then swipes card by card.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ericts.com/r/use-element-size-map.json"
  ],
  "files": [
    {
      "path": "registry/base/blocks/deck-lift.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  type PanInfo,\n} from \"motion/react\";\n\nimport { useElementSizeMap } from \"@/hooks/use-element-size-map\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DeckLiftItem = {\n  id: string;\n  /** Accessible name for the card; also labels its detail panel. */\n  label: string;\n  /** The card face. One element in both states — it is never re-created. */\n  face: React.ReactNode;\n  /** Fills the page under the docked deck while this card is the active one. */\n  detail: React.ReactNode;\n};\n\nexport type DeckLiftProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"children\" | \"defaultValue\" | \"onChange\"\n> & {\n  items: readonly DeckLiftItem[];\n  /** The page the deck rests on. Covered, never unmounted, while open. */\n  children?: React.ReactNode;\n  /** Raised sheet over the resting deck; it drops away as a card opens. */\n  sheet?: React.ReactNode;\n  /** Open card id, or `null` for the resting deck. */\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (id: string | null, item: DeckLiftItem | null) => void;\n  /** Cards the resting pile shows. The rest wait on its last slot. */\n  pileSize?: number;\n  deckLabel?: string;\n  closeLabel?: string;\n  cardClassName?: string;\n  sheetClassName?: string;\n  detailClassName?: string;\n};\n\n/* -------------------------------------------------------------------------\n * The move, in numbers. Start here — everything below is plumbing.\n * ---------------------------------------------------------------------- */\n\n/** The resting pile. */\nconst REST = {\n  /** Its top edge, as a share of the stage height. */\n  top: 0.56,\n  /** How far each card behind the front one rises out of it, in px. */\n  rise: 18,\n  shrink: 0.04,\n  /** Front card's tilt in degrees; the pile straightens as it goes back. */\n  tilt: -2.5,\n  tiltStep: 1.2,\n} as const;\n\n/** The row the pile docks into. */\nconst DOCK = {\n  /** Distance from the stage top, leaving room for the close control, in px. */\n  top: 56,\n  /** Between docked cards, and between the deck and the detail, in px. */\n  gap: 10,\n  /** More breathing room between the visible card edges on narrow stages. */\n  narrowGap: 16,\n  detailGap: 24,\n  /**\n   * How much smaller each card is per step away from the active one. Deep\n   * enough that the card being read is plainly the nearest thing on the row:\n   * the pile's own 0.04 a slot left the neighbours all but the same size as the\n   * active card, which read as a flat strip rather than as one card in front of\n   * the others. At 0.14, the immediate neighbours sit at 86% and the next layer\n   * at 72%: enough separation to identify the active card without making the\n   * alternatives look disabled.\n   */\n  recede: 0.14,\n  /**\n   * Steps away before a card stops receding. Without a floor the far end of a\n   * long deck shrinks to nothing, and a deck of thirty would be a different\n   * shape from a deck of three.\n   */\n  depth: 2,\n} as const;\n\n/**\n * Distance between card centres in the docked row.\n *\n * A narrow stage cannot show two full cards, so leaving the centres one full\n * card apart makes the scaled neighbour almost impossible to tap: at the\n * default mobile geometry only a few pixels survive the stage clip. There the\n * step compensates for half of the neighbour's shrink, then keeps a slightly\n * roomier visible gap between the edges. Once two cards fit, the uncompensated\n * spacing returns so the extra air can keep expressing depth on tablet and\n * desktop.\n *\n * Exported because jsdom cannot measure the rendered card and stage boxes.\n */\nexport function deckLiftDockStep({\n  stageWidth,\n  cardWidth,\n}: {\n  stageWidth: number;\n  cardWidth: number;\n}) {\n  if (!cardWidth) return 0;\n\n  const naturalStep = cardWidth + DOCK.gap;\n  const narrow = stageWidth > 0 && stageWidth < cardWidth * 2;\n\n  if (!narrow) return naturalStep;\n\n  return cardWidth * (1 - DOCK.recede / 2) + DOCK.narrowGap;\n}\n\n/** The swipe between docked cards. */\nconst SWIPE = {\n  /** Share of a card's width a drag must cover before it commits. */\n  threshold: 0.24,\n  /** Seconds of flick velocity folded into the drag's travel. */\n  velocity: 0.12,\n  /**\n   * Cards the flick alone may be worth. Speed decides *whether* a gesture\n   * commits, not how far it goes: a hard flick from a standstill is one flick\n   * and means the next card, but at 4000px/s its raw contribution is more than\n   * a card and a half, which rounded up to two — the row lurching past the card\n   * the finger clearly meant. Travel stays uncapped, because a drag that\n   * covered three cards was aimed at three cards.\n   */\n  flickReach: 1,\n  elastic: 0.12,\n} as const;\n\n/** The page of detail under the docked deck. */\nconst DETAIL = {\n  /**\n   * Share of the deck's travel a page of detail covers as it is swapped out for\n   * the next. Under 1, so the page reads as lying further back than the cards —\n   * the same screen trailing the deck, rather than a second row moving in\n   * lockstep with it. It is the only number here: the distance and the timing\n   * both come off the deck, so the two layers cannot drift apart when either is\n   * re-tuned.\n   *\n   * It is spent on the *swap* and nothing else. A live drag leaves the page\n   * alone: the cards are the thing under the finger, and text sliding around\n   * beneath them turns every hesitant half-swipe into a page that will not hold\n   * still long enough to be read.\n   */\n  swapTravel: 0.22,\n} as const;\n\nconst SPRING = {\n  /** The lift. Deck, cover and sheet all ride this one spring. */\n  stage: { type: \"spring\", duration: 0.58, bounce: 0.16 },\n  /** The shorter snap between docked cards. */\n  snap: { type: \"spring\", duration: 0.42, bounce: 0.12 },\n  /** The trade of one page of detail for the next. Its *travel* is not here:\n   *  that rides `snap` above, so the page and the cards over it settle as one\n   *  move. This is only the hand-off, and it is deliberately lopsided — the\n   *  leaving page clears out ahead of the arriving one, because two pages of\n   *  the same shape held at half opacity over the same pixels read as a smear\n   *  rather than as one being replaced by the other. */\n  detailLeave: { duration: 0.18, ease: [0.4, 0, 1, 1] },\n  detailArrive: { duration: 0.2, delay: 0.06, ease: [0.22, 1, 0.36, 1] },\n  /** Cards joining the lift are there from its first frame; on the way back\n   *  they only fade once landed, so the pile never looks like it dissolves. */\n  arrive: { duration: 0.12 },\n  leave: { duration: 0.2, delay: 0.18 },\n  /** Reduced motion drops travel, not feedback: a swap still has to be seen,\n   *  and a cross-fade is not movement. */\n  fade: { duration: 0.15 },\n  still: { duration: 0 },\n} as const;\n\n/**\n * Where card `index` sits, in both states — the whole card animation. The pile\n * fans up and back; the docked row lays the same cards out sideways, receding\n * with distance from the active card; cards past the pile wait invisibly on its\n * last slot until the lift starts.\n */\nfunction cardState({\n  open,\n  index,\n  activeIndex,\n  deepestSlot,\n  step,\n  dockY,\n}: {\n  open: boolean;\n  index: number;\n  activeIndex: number;\n  deepestSlot: number;\n  /** Distance between docked cards, measured from the rendered card. */\n  step: number;\n  /** Travel from the resting pile to the docked row. */\n  dockY: number;\n}) {\n  if (open) {\n    // Graded by distance rather than by \"is this the active card\": one card at\n    // full size in an otherwise flat strip reads as a gap in the row, where a\n    // row that keeps receding reads as depth.\n    //\n    // The cards share one grid cell and are held apart by `x` alone, with\n    // `scale` applied about each card's centre. `step` only compensates that\n    // shrink when the stage is too narrow to show two cards; on wider stages\n    // the widening gaps remain part of the depth. Drag uses the same step, so\n    // what the finger crosses and what the row advances never diverge.\n    const away = Math.min(Math.abs(index - activeIndex), DOCK.depth);\n\n    return {\n      transform: `translate3d(${index * step}px, ${dockY}px, 0) rotate(0deg) scale(${1 - away * DOCK.recede})`,\n      opacity: 1,\n    };\n  }\n\n  const slot = Math.min(index, deepestSlot);\n\n  return {\n    transform: `translate3d(0, ${-slot * REST.rise}px, 0) rotate(${REST.tilt + slot * REST.tiltStep}deg) scale(${1 - slot * REST.shrink})`,\n    opacity: index > deepestSlot ? 0 : 1,\n  };\n}\n\n/**\n * Which card a finished drag lands on, or `activeIndex` when it was too short\n * to commit. Travel is the finger's distance plus a capped slice of its parting\n * speed, so a flick that barely moves still commits, a flick however hard is\n * worth one card, and one long drag can still cross several. Exported so the\n * velocity arithmetic can be exercised without synthesizing a drag.\n */\nexport function deckLiftSwipeTarget({\n  offsetX,\n  velocityX,\n  step,\n  activeIndex,\n  count,\n}: {\n  offsetX: number;\n  velocityX: number;\n  /** Distance between docked cards. Zero before the deck is measured. */\n  step: number;\n  activeIndex: number;\n  count: number;\n}) {\n  if (!step) return activeIndex;\n\n  const flick = (velocityX * SWIPE.velocity) / step;\n  const cards =\n    offsetX / step +\n    Math.min(Math.max(flick, -SWIPE.flickReach), SWIPE.flickReach);\n\n  if (Math.abs(cards) < SWIPE.threshold) return activeIndex;\n\n  // Dragging right reveals the card before the active one.\n  const towards = cards > 0 ? -1 : 1;\n  const reach = Math.max(1, Math.round(Math.abs(cards)));\n\n  return Math.min(Math.max(activeIndex + towards * reach, 0), count - 1);\n}\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\n/**\n * A screen in three layers — a page of summary values, a raised sheet, and a\n * deck of cards between them. Opening a card lifts the whole deck to the top,\n * drops the sheet away, and pulls a full page of detail up behind the cards,\n * which then swipe as a row with the detail following underneath.\n *\n * Why it is built this way: the card the finger touched is the same element\n * that ends up docked at the top — nothing cross-fades, nothing is re-created —\n * so the move reads as *this screen became that card's screen*. The cover rides\n * up behind the cards on the same spring, so the summary values are wiped away\n * by the motion the eye is already following rather than by a scrim.\n *\n * @example\n *   <DeckLift items={cards} sheet={<Sheet />} className=\"h-dvh\">\n *     <Dashboard />\n *   </DeckLift>\n *\n * - The stage is this block's own box, not the viewport, and it is a container:\n *   the card and the reading column widen with it, so the same screen works on a\n *   phone, a tablet and a desktop without a device prop.\n * - Card width is `--deck-lift-card-width`, a clamp that grows with the stage.\n *   The docked row, the swipe step and the detail offset are all measured from\n *   the card it produces, so overriding that one property moves the whole\n *   composition with it.\n * - It clips with `overflow-clip`: a scroll container would let the browser\n *   scroll a focused card into view and drag every layer with it.\n * - The layers under the open cover are `inert`, which contains focus without a\n *   hand-written trap; a card is one element in both states, so focus stays on\n *   whatever was pressed and only Escape has to put it back.\n * - The layers are ordered in the DOM the way the screen reads down — control\n *   row, cards, detail — and stacked with `z-index`, so Tab follows the eye\n *   rather than the paint order.\n * - Every position is measured, so a resize changes the same targets a press\n *   does. Those land instantly: a resize is not a move anyone made.\n * - `prefers-reduced-motion` drops the travel and keeps the fades, so a swap is\n *   still visible without anything crossing the screen.\n * - Cards bring no surface of their own. Give them a shadow through\n *   `cardClassName` or the pile's layers merge.\n */\nexport function DeckLift({\n  items,\n  children,\n  sheet,\n  value,\n  defaultValue = null,\n  onValueChange,\n  pileSize = 2,\n  deckLabel = \"Cards\",\n  closeLabel = \"Close\",\n  className,\n  ref,\n  cardClassName,\n  sheetClassName,\n  detailClassName,\n  ...props\n}: DeckLiftProps) {\n  const reduceMotion = useReducedMotion() === true;\n  const reactId = React.useId();\n  const panelId = `${reactId}-panel`;\n  // Stable, so the focus helper built on it can be stable too.\n  const cardId = React.useCallback(\n    (id: string) => `${reactId}-card-${id}`,\n    [reactId],\n  );\n  const { setMeasureRef, sizes } = useElementSizeMap<HTMLElement>();\n  const stageNode = React.useRef<HTMLDivElement | null>(null);\n  const detailScroller = React.useRef<HTMLDivElement | null>(null);\n  const dragged = React.useRef(false);\n\n  const isControlled = value !== undefined;\n  const [uncontrolled, setUncontrolled] = React.useState<string | null>(\n    defaultValue,\n  );\n  const openId =\n    items.find((item) => item.id === (isControlled ? value : uncontrolled))\n      ?.id ?? null;\n  const open = openId !== null;\n\n  // A card removed from `items` while it was open leaves the deck closed. The\n  // stored id has to go with it, or putting that card back would re-open the\n  // deck on its own.\n  if (!isControlled && uncontrolled !== null && openId === null) {\n    setUncontrolled(null);\n  }\n\n  const stageWidth = sizes.stage?.width ?? 0;\n  const stageHeight = sizes.stage?.height ?? 0;\n  const cardWidth = sizes.card?.width ?? 0;\n  const cardHeight = sizes.card?.height ?? 0;\n  const step = deckLiftDockStep({ stageWidth, cardWidth });\n  const dockY = stageHeight ? DOCK.top - REST.top * stageHeight : 0;\n  const deepestSlot = Math.max(0, Math.floor(pileSize) - 1);\n\n  // What the deck last settled on. Every position below is measured, so a target\n  // moves on a resize as much as on a press — and on the first render nothing is\n  // measured at all. Neither is a move anyone made: without this the deck springs\n  // after every ResizeObserver frame, and a deck that starts open springs in from\n  // wherever the unmeasured layout put it. Adjusted during render, because the\n  // transition this picks has to reach the DOM in the same commit as the target\n  // it applies to.\n  const [settled, setSettled] = React.useState({\n    openId,\n    step,\n    dockY,\n    instant: false,\n  });\n\n  const pressed = settled.openId !== openId;\n  const measured = settled.step !== step || settled.dockY !== dockY;\n\n  if (pressed || measured) {\n    setSettled({ openId, step, dockY, instant: measured && !pressed });\n  }\n\n  // A render that changes neither leaves this as it was, which is harmless: with\n  // no target to move, nothing animates either way.\n  const instant = reduceMotion || settled.instant;\n\n  const stageSpring = instant ? SPRING.still : SPRING.stage;\n  const snapSpring = instant ? SPRING.still : SPRING.snap;\n\n  // The detail outlives the open state, so closing never blanks the page\n  // mid-flight, and it remembers which way the deck moved so it can leave the\n  // same way. Synced in render — an effect would swap it a frame late.\n  const [detail, setDetail] = React.useState(() => ({\n    id: openId ?? items[0]?.id ?? \"\",\n    direction: 0,\n  }));\n\n  if (open && openId !== detail.id) {\n    const from = items.findIndex((item) => item.id === detail.id);\n    const to = items.findIndex((item) => item.id === openId);\n\n    setDetail({\n      id: openId,\n      // Opening from rest is not a lateral move: the deck arrives with this card\n      // at the front, so its detail belongs there from the first frame rather\n      // than sliding in past a card the row was never showing. Only a swipe or\n      // an arrow key, which moves the row sideways, gives a swap its direction.\n      direction:\n        settled.openId !== null && from !== -1 ? (to < from ? -1 : 1) : 0,\n    });\n  } else if (!open) {\n    // The card the detail was showing can disappear from `items`. Fall back\n    // without a direction rather than sliding to a neighbour it never had.\n    const fallback = items.some((item) => item.id === detail.id)\n      ? detail.id\n      : (items[0]?.id ?? \"\");\n\n    if (fallback !== detail.id) setDetail({ id: fallback, direction: 0 });\n  }\n\n  const activeIndex = Math.max(\n    0,\n    items.findIndex((item) => item.id === detail.id),\n  );\n  const activeItem = items[activeIndex];\n  const direction = detail.direction;\n\n  // Reduced motion drops the swap's travel and keeps its hand-off — see `fade`\n  // above.\n  const swapTravel = reduceMotion ? 0 : DETAIL.swapTravel;\n\n  // The deck's x is a motion value because a drag writes to the same value:\n  // after a drag that commits nothing there is no new target for an `animate`\n  // prop to re-apply, and `dragSnapToOrigin` would send the deck to its layout\n  // origin — the first card — instead of the one being looked at.\n  const deckX = useMotionValue(0);\n  const deckTarget = open ? -activeIndex * step : 0;\n  const deckTargetRef = React.useRef(deckTarget);\n\n  React.useEffect(() => {\n    deckTargetRef.current = deckTarget;\n\n    const controls = animate(deckX, deckTarget, snapSpring);\n    return () => controls.stop();\n  }, [deckTarget, deckX, snapSpring]);\n\n  // The page of detail moves on a swap and at no other time. It is deliberately\n  // not wired to the drag: the deck is the layer the finger has hold of, and a\n  // page that leans out under every uncommitted quarter-swipe is a paragraph\n  // being tugged away from the eye that is reading it. So it holds still until\n  // the release decides, and then trades itself for the next one.\n  const detailVariants = React.useMemo(() => {\n    const travel = step * swapTravel;\n    const at = (x: number) => `translate3d(${x}px, 0, 0)`;\n\n    return {\n      enter: (towards: number) => ({\n        opacity: 0,\n        transform: at(towards * travel),\n      }),\n      center: {\n        opacity: 1,\n        transform: at(0),\n        transition: {\n          transform: snapSpring,\n          opacity: SPRING.detailArrive,\n        },\n      },\n      exit: (towards: number) => ({\n        opacity: 0,\n        transform: at(towards * -travel),\n        transition: SPRING.detailLeave,\n      }),\n    };\n  }, [snapSpring, step, swapTravel]);\n\n  /**\n   * Focus a card by id rather than through a map of refs. Holding that map cost\n   * the deck its measurement: a per-card `ref` has to be written inline to\n   * close over the card's id, which makes it a new function every render, so\n   * React re-attaches it on every commit and re-measures the card it is on.\n   * That measurement is `step`, which every position here is built from, so the\n   * churn dragged the whole docked row about under the finger. Every card\n   * already carries a stable `id`; one lookup is enough.\n   */\n  const focusCard = React.useCallback(\n    (id: string) => {\n      const doc = stageNode.current?.ownerDocument ?? document;\n\n      doc.getElementById(cardId(id))?.focus();\n    },\n    [cardId],\n  );\n\n  const setOpenValue = React.useCallback(\n    (nextId: string | null) => {\n      const next = items.find((item) => item.id === nextId) ?? null;\n\n      if (!isControlled) setUncontrolled(next?.id ?? null);\n\n      onValueChange?.(next?.id ?? null, next);\n    },\n    [isControlled, items, onValueChange],\n  );\n\n  const select = React.useCallback(\n    (index: number, focus = false) => {\n      const next = items[Math.min(Math.max(index, 0), items.length - 1)];\n\n      if (!next || next.id === detail.id) return;\n\n      setOpenValue(next.id);\n\n      if (focus) focusCard(next.id);\n    },\n    [detail.id, focusCard, items, setOpenValue],\n  );\n\n  // The detail is one scroller every card reuses, so a card that opens after a\n  // long one would otherwise arrive already scrolled. A layout effect, so the\n  // swap and the reset land in the same frame, and never while closing, where the\n  // jump would be on screen.\n  useIsomorphicLayoutEffect(() => {\n    if (!open || !detailScroller.current) return;\n\n    detailScroller.current.scrollTop = 0;\n  }, [open, detail.id]);\n\n  const close = React.useCallback(() => {\n    // The open card is the element that was pressed to get here, so returning\n    // focus to it is the only focus move this block makes.\n    focusCard(detail.id);\n    setOpenValue(null);\n  }, [detail.id, focusCard, setOpenValue]);\n\n  // Read through a ref: with an inline `onValueChange`, `close` changes identity\n  // on every parent render, and the Escape listener must not re-bind that often.\n  const closeRef = React.useRef(close);\n\n  React.useEffect(() => {\n    closeRef.current = close;\n  });\n\n  React.useEffect(() => {\n    if (!open) return;\n\n    // Clicking dead space inside a page moves focus to the body, so this has to\n    // be the document rather than the stage. `defaultPrevented` yields to a\n    // popover or combobox inside the detail that took Escape first, and marking\n    // the event afterwards lets a dialog this block sits inside skip it too.\n    const doc = stageNode.current?.ownerDocument ?? document;\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\" || event.defaultPrevented) return;\n\n      event.preventDefault();\n      closeRef.current();\n    };\n\n    doc.addEventListener(\"keydown\", onKeyDown);\n    return () => doc.removeEventListener(\"keydown\", onKeyDown);\n  }, [open]);\n\n  const onDeckKeyDown = (event: React.KeyboardEvent) => {\n    if (!open) return;\n\n    const to = {\n      ArrowRight: activeIndex + 1,\n      ArrowLeft: activeIndex - 1,\n      Home: 0,\n      End: items.length - 1,\n    }[event.key];\n\n    if (to === undefined) return;\n\n    event.preventDefault();\n    select(to, true);\n  };\n\n  const onDragEnd = (_event: unknown, info: PanInfo) => {\n    const target = deckLiftSwipeTarget({\n      offsetX: info.offset.x,\n      velocityX: info.velocity.x,\n      step,\n      activeIndex,\n      count: items.length,\n    });\n\n    // Every release settles the row, not only an uncommitted one: nothing else\n    // would pull it off the offset the finger left it at.\n    //\n    // Where it settles *to* is the whole difference. Uncontrolled, this\n    // component decides which card wins, so the row is sent straight to it and\n    // the retarget above lands on the same value — one continuous spring. Sent\n    // to the card being left instead, as it was, the row travels back under the\n    // finger's own direction for the render it takes to commit and only then\n    // turns around: a kick on every release that crosses a card, and the whole\n    // reason a swipe felt like it jumped.\n    //\n    // Controlled, that shortcut is not available. The owner may decline the\n    // swipe, and a row already on its way to a card it was never given would be\n    // showing one screen under another's detail — so it goes back to the card on\n    // screen and waits to be told, which is the one case where the kick is the\n    // honest answer.\n    animate(\n      deckX,\n      isControlled ? deckTargetRef.current : -target * step,\n      snapSpring,\n    );\n\n    select(target);\n\n    // The click that follows a drag has to be swallowed, but the flag has to be\n    // gone before the next keyboard activation, which arrives with no pointer\n    // event to clear it.\n    requestAnimationFrame(() => {\n      dragged.current = false;\n    });\n  };\n\n  const setStageRef = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      stageNode.current = node;\n      setMeasureRef(\"stage\")(node);\n\n      if (typeof ref === \"function\") {\n        ref(node);\n      } else if (ref) {\n        ref.current = node;\n      }\n    },\n    [ref, setMeasureRef],\n  );\n\n  return (\n    <div\n      ref={setStageRef}\n      data-slot=\"deck-lift\"\n      data-state={open ? \"open\" : \"closed\"}\n      className={cn(\n        \"@container relative isolate overflow-clip bg-background [contain:layout_paint]\",\n        // The one width the composition is built from: it grows with the stage\n        // and stops at a card-sized cap, so a phone, a tablet and a desktop all\n        // get a card rather than a stretched one. Every other measurement is\n        // taken from the card this produces. (A `@container` cannot query\n        // itself, so this is a clamp rather than container-query breakpoints.)\n        \"[--deck-lift-card-width:clamp(15rem,calc(100%-4.5rem),24rem)]\",\n        // How far from the middle a card is gone. The row stays full width and\n        // fully draggable; this only says how much of it is being presented.\n        \"[--deck-lift-focus:var(--deck-lift-card-width)]\",\n        className,\n      )}\n      {...props}\n    >\n      {/* The page. It never moves; it is covered. */}\n      <div\n        data-slot=\"deck-lift-page\"\n        inert={open}\n        aria-hidden={open || undefined}\n        className=\"absolute inset-0 z-0\"\n      >\n        {children}\n      </div>\n\n      {/* The control row. Its own layer on the cover's spring, so the close\n          button can be read — and reached — before the cards it sits above:\n          `z-index` orders what is painted, the DOM orders what Tab visits. */}\n      <motion.div\n        data-slot=\"deck-lift-controls\"\n        inert={!open}\n        aria-hidden={open ? undefined : true}\n        initial={false}\n        animate={{\n          transform: open\n            ? \"translate3d(0, 0, 0)\"\n            : stageHeight\n              ? `translate3d(0, ${stageHeight}px, 0)`\n              : \"translate3d(0, 100dvh, 0)\",\n        }}\n        transition={stageSpring}\n        style={{ height: DOCK.top }}\n        className=\"pointer-events-none absolute inset-x-0 top-0 z-40\"\n      >\n        <div className=\"flex size-full items-center px-3\">\n          <button\n            type=\"button\"\n            onClick={close}\n            className=\"pointer-events-auto inline-flex size-8 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <svg\n              aria-hidden=\"true\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              className=\"size-4\"\n            >\n              <path d=\"M18 6 6 18M6 6l12 12\" />\n            </svg>\n            <span className=\"sr-only\">{closeLabel}</span>\n          </button>\n        </div>\n      </motion.div>\n\n      {/* The deck: a pile at rest, a swipeable row once open.\n\n          Two boxes, because the mask has to hold still. It lives on this frame\n          rather than on the row, which is translated by `x` — a mask travels\n          with the box it is set on, so one applied to the row would slide off\n          centre along with it. */}\n      <div\n        data-slot=\"deck-lift-focus\"\n        data-swipe-navigation=\"ignore\"\n        className={cn(\n          // Fill the stage vertically. The cards travel from the resting pile\n          // to the dock, but the desktop mask must not travel or keep the\n          // resting pile's short border box: masked overflow outside that box\n          // is transparent, which made the entire docked deck disappear.\n          \"pointer-events-none absolute inset-0 z-20\",\n          // Only once the stage has more width than the composition wants. On a\n          // phone the card already fills it and the neighbours see themselves\n          // off; from here up the stage keeps growing while the card stops at\n          // its cap, so the spare width turns into *more cards* rather than a\n          // bigger one — three of them at desktop, none of which says which one\n          // the detail below belongs to. Every other layer here caps its own\n          // width for the same reason; this is the deck's version of that.\n          \"@2xl:mask-[linear-gradient(to_right,transparent_calc(50%_-_var(--deck-lift-focus)),black_calc(50%_-_var(--deck-lift-card-width)/2),black_calc(50%_+_var(--deck-lift-card-width)/2),transparent_calc(50%_+_var(--deck-lift-focus)))]\",\n        )}\n      >\n        <motion.div\n          data-slot=\"deck-lift-deck\"\n          role={open ? \"tablist\" : undefined}\n          aria-label={open ? deckLabel : undefined}\n          aria-orientation={open ? \"horizontal\" : undefined}\n          drag={open && items.length > 1 ? \"x\" : false}\n          dragElastic={SWIPE.elastic}\n          dragMomentum={false}\n          dragConstraints={{ left: -(items.length - 1) * step, right: 0 }}\n          onPointerDownCapture={() => {\n            dragged.current = false;\n          }}\n          onDragStart={() => {\n            dragged.current = true;\n          }}\n          onDragEnd={onDragEnd}\n          onKeyDown={onDeckKeyDown}\n          initial={false}\n          style={{ x: deckX, top: `${REST.top * 100}%` }}\n          className=\"absolute inset-x-0 grid justify-items-center\"\n        >\n          {items.map((item, index) => {\n            const active = open && item.id === detail.id;\n            const waiting = !open && index > deepestSlot;\n\n            return (\n              <motion.button\n                key={item.id}\n                id={cardId(item.id)}\n                // The deck's geometry is read off the first rendered card.\n                // `setMeasureRef` caches per key, so this is the same function\n                // every render and React leaves the subscription alone.\n                ref={index === 0 ? setMeasureRef(\"card\") : undefined}\n                type=\"button\"\n                aria-label={item.label}\n                role={open ? \"tab\" : undefined}\n                aria-selected={open ? active : undefined}\n                aria-controls={open ? panelId : undefined}\n                aria-expanded={open ? undefined : false}\n                tabIndex={open && !active ? -1 : 0}\n                inert={waiting}\n                data-slot=\"deck-lift-card\"\n                data-active={active || undefined}\n                onClick={() => {\n                  if (dragged.current) return;\n                  if (open) select(index);\n                  else setOpenValue(item.id);\n                }}\n                initial={false}\n                animate={cardState({\n                  open,\n                  index,\n                  activeIndex,\n                  deepestSlot,\n                  step,\n                  dockY,\n                })}\n                transition={{\n                  ...stageSpring,\n                  opacity: reduceMotion\n                    ? SPRING.fade\n                    : open\n                      ? SPRING.arrive\n                      : SPRING.leave,\n                }}\n                style={{\n                  zIndex:\n                    items.length -\n                    (open ? Math.abs(index - activeIndex) : index),\n                  touchAction: open ? \"pan-y\" : undefined,\n                }}\n                className={cn(\n                  \"pointer-events-auto aspect-[1.586/1] w-(--deck-lift-card-width) overflow-hidden rounded-2xl text-left outline-none [grid-area:1/1] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n                  cardClassName,\n                )}\n              >\n                {item.face}\n              </motion.button>\n            );\n          })}\n        </motion.div>\n      </div>\n\n      {/* The cover, rising behind the cards to wipe the page away. */}\n      <motion.div\n        data-slot=\"deck-lift-cover\"\n        inert={!open}\n        aria-hidden={open ? undefined : true}\n        initial={false}\n        animate={{ y: open ? \"0%\" : \"100%\" }}\n        transition={stageSpring}\n        className=\"absolute inset-0 z-10 flex flex-col bg-background\"\n      >\n        {/* The control row and the floating deck both sit over this. */}\n        <div\n          aria-hidden=\"true\"\n          className=\"shrink-0\"\n          style={{ height: DOCK.top + cardHeight + DOCK.detailGap }}\n        />\n\n        <div\n          id={panelId}\n          role={open ? \"tabpanel\" : undefined}\n          aria-labelledby={\n            open && activeItem ? cardId(activeItem.id) : undefined\n          }\n          data-slot=\"deck-lift-detail\"\n          className={cn(\"flex min-h-0 flex-1 flex-col py-2\", detailClassName)}\n        >\n          {/* Padding stays on this frame so it does not scroll away. */}\n          <div\n            ref={detailScroller}\n            className=\"min-h-0 flex-1 overflow-x-clip overflow-y-auto\"\n          >\n            <div className=\"mx-auto grid w-full max-w-2xl @5xl:max-w-3xl\">\n              <AnimatePresence initial={false} custom={direction}>\n                {activeItem ? (\n                  <motion.div\n                    key={activeItem.id}\n                    custom={direction}\n                    variants={detailVariants}\n                    initial=\"enter\"\n                    animate=\"center\"\n                    exit=\"exit\"\n                    className=\"min-w-0 [grid-area:1/1]\"\n                  >\n                    {activeItem.detail}\n                  </motion.div>\n                ) : null}\n              </AnimatePresence>\n            </div>\n          </div>\n        </div>\n      </motion.div>\n\n      {/* The sheet, dropping out from over the pile. */}\n      {sheet ? (\n        <motion.div\n          data-slot=\"deck-lift-sheet\"\n          inert={open}\n          aria-hidden={open || undefined}\n          initial={false}\n          animate={{ y: open ? \"100%\" : \"0%\" }}\n          transition={stageSpring}\n          className={cn(\n            \"absolute inset-x-0 bottom-0 z-30 mx-auto w-full rounded-t-[1.75rem] border-t bg-background shadow-xl @2xl:max-w-2xl @2xl:rounded-t-[2rem] @2xl:border-x @5xl:max-w-3xl\",\n            sheetClassName,\n          )}\n        >\n          {sheet}\n        </motion.div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:block",
      "target": "@components/blocks/deck-lift.tsx"
    }
  ],
  "meta": {
    "tags": [
      "deck",
      "pile",
      "cards",
      "detail",
      "swipe",
      "drag",
      "container-query",
      "controlled"
    ],
    "effects": [
      "deck-lift",
      "cover-rise",
      "sheet-drop",
      "card-swipe",
      "detail-swap",
      "reduced-motion"
    ]
  },
  "categories": [
    "app-pattern"
  ],
  "type": "registry:block"
}