Back to blocks

Deck Lift

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.

app-patterndeck-lift
Page
Sheet
"use client";

import * as React from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  type PanInfo,
} from "motion/react";

import { useElementSizeMap } from "@/hooks/use-element-size-map";
import { cn } from "@/lib/utils";

export type DeckLiftItem = {
  id: string;
  /** Accessible name for the card; also labels its detail panel. */
  label: string;
  /** The card face. One element in both states — it is never re-created. */
  face: React.ReactNode;
  /** Fills the page under the docked deck while this card is the active one. */
  detail: React.ReactNode;
};

export type DeckLiftProps = Omit<
  React.ComponentProps<"div">,
  "children" | "defaultValue" | "onChange"
> & {
  items: readonly DeckLiftItem[];
  /** The page the deck rests on. Covered, never unmounted, while open. */
  children?: React.ReactNode;
  /** Raised sheet over the resting deck; it drops away as a card opens. */
  sheet?: React.ReactNode;
  /** Open card id, or `null` for the resting deck. */
  value?: string | null;
  defaultValue?: string | null;
  onValueChange?: (id: string | null, item: DeckLiftItem | null) => void;
  /** Cards the resting pile shows. The rest wait on its last slot. */
  pileSize?: number;
  deckLabel?: string;
  closeLabel?: string;
  cardClassName?: string;
  sheetClassName?: string;
  detailClassName?: string;
};

/* -------------------------------------------------------------------------
 * The move, in numbers. Start here — everything below is plumbing.
 * ---------------------------------------------------------------------- */

/** The resting pile. */
const REST = {
  /** Its top edge, as a share of the stage height. */
  top: 0.56,
  /** How far each card behind the front one rises out of it, in px. */
  rise: 18,
  shrink: 0.04,
  /** Front card's tilt in degrees; the pile straightens as it goes back. */
  tilt: -2.5,
  tiltStep: 1.2,
} as const;

/** The row the pile docks into. */
const DOCK = {
  /** Distance from the stage top, leaving room for the close control, in px. */
  top: 56,
  /** Between docked cards, and between the deck and the detail, in px. */
  gap: 10,
  /** More breathing room between the visible card edges on narrow stages. */
  narrowGap: 16,
  detailGap: 24,
  /**
   * How much smaller each card is per step away from the active one. Deep
   * enough that the card being read is plainly the nearest thing on the row:
   * the pile's own 0.04 a slot left the neighbours all but the same size as the
   * active card, which read as a flat strip rather than as one card in front of
   * the others. At 0.14, the immediate neighbours sit at 86% and the next layer
   * at 72%: enough separation to identify the active card without making the
   * alternatives look disabled.
   */
  recede: 0.14,
  /**
   * Steps away before a card stops receding. Without a floor the far end of a
   * long deck shrinks to nothing, and a deck of thirty would be a different
   * shape from a deck of three.
   */
  depth: 2,
} as const;

/**
 * Distance between card centres in the docked row.
 *
 * A narrow stage cannot show two full cards, so leaving the centres one full
 * card apart makes the scaled neighbour almost impossible to tap: at the
 * default mobile geometry only a few pixels survive the stage clip. There the
 * step compensates for half of the neighbour's shrink, then keeps a slightly
 * roomier visible gap between the edges. Once two cards fit, the uncompensated
 * spacing returns so the extra air can keep expressing depth on tablet and
 * desktop.
 *
 * Exported because jsdom cannot measure the rendered card and stage boxes.
 */
export function deckLiftDockStep({
  stageWidth,
  cardWidth,
}: {
  stageWidth: number;
  cardWidth: number;
}) {
  if (!cardWidth) return 0;

  const naturalStep = cardWidth + DOCK.gap;
  const narrow = stageWidth > 0 && stageWidth < cardWidth * 2;

  if (!narrow) return naturalStep;

  return cardWidth * (1 - DOCK.recede / 2) + DOCK.narrowGap;
}

/** The swipe between docked cards. */
const SWIPE = {
  /** Share of a card's width a drag must cover before it commits. */
  threshold: 0.24,
  /** Seconds of flick velocity folded into the drag's travel. */
  velocity: 0.12,
  /**
   * Cards the flick alone may be worth. Speed decides *whether* a gesture
   * commits, not how far it goes: a hard flick from a standstill is one flick
   * and means the next card, but at 4000px/s its raw contribution is more than
   * a card and a half, which rounded up to two — the row lurching past the card
   * the finger clearly meant. Travel stays uncapped, because a drag that
   * covered three cards was aimed at three cards.
   */
  flickReach: 1,
  elastic: 0.12,
} as const;

/** The page of detail under the docked deck. */
const DETAIL = {
  /**
   * Share of the deck's travel a page of detail covers as it is swapped out for
   * the next. Under 1, so the page reads as lying further back than the cards —
   * the same screen trailing the deck, rather than a second row moving in
   * lockstep with it. It is the only number here: the distance and the timing
   * both come off the deck, so the two layers cannot drift apart when either is
   * re-tuned.
   *
   * It is spent on the *swap* and nothing else. A live drag leaves the page
   * alone: the cards are the thing under the finger, and text sliding around
   * beneath them turns every hesitant half-swipe into a page that will not hold
   * still long enough to be read.
   */
  swapTravel: 0.22,
} as const;

const SPRING = {
  /** The lift. Deck, cover and sheet all ride this one spring. */
  stage: { type: "spring", duration: 0.58, bounce: 0.16 },
  /** The shorter snap between docked cards. */
  snap: { type: "spring", duration: 0.42, bounce: 0.12 },
  /** The trade of one page of detail for the next. Its *travel* is not here:
   *  that rides `snap` above, so the page and the cards over it settle as one
   *  move. This is only the hand-off, and it is deliberately lopsided — the
   *  leaving page clears out ahead of the arriving one, because two pages of
   *  the same shape held at half opacity over the same pixels read as a smear
   *  rather than as one being replaced by the other. */
  detailLeave: { duration: 0.18, ease: [0.4, 0, 1, 1] },
  detailArrive: { duration: 0.2, delay: 0.06, ease: [0.22, 1, 0.36, 1] },
  /** Cards joining the lift are there from its first frame; on the way back
   *  they only fade once landed, so the pile never looks like it dissolves. */
  arrive: { duration: 0.12 },
  leave: { duration: 0.2, delay: 0.18 },
  /** Reduced motion drops travel, not feedback: a swap still has to be seen,
   *  and a cross-fade is not movement. */
  fade: { duration: 0.15 },
  still: { duration: 0 },
} as const;

/**
 * Where card `index` sits, in both states — the whole card animation. The pile
 * fans up and back; the docked row lays the same cards out sideways, receding
 * with distance from the active card; cards past the pile wait invisibly on its
 * last slot until the lift starts.
 */
function cardState({
  open,
  index,
  activeIndex,
  deepestSlot,
  step,
  dockY,
}: {
  open: boolean;
  index: number;
  activeIndex: number;
  deepestSlot: number;
  /** Distance between docked cards, measured from the rendered card. */
  step: number;
  /** Travel from the resting pile to the docked row. */
  dockY: number;
}) {
  if (open) {
    // Graded by distance rather than by "is this the active card": one card at
    // full size in an otherwise flat strip reads as a gap in the row, where a
    // row that keeps receding reads as depth.
    //
    // The cards share one grid cell and are held apart by `x` alone, with
    // `scale` applied about each card's centre. `step` only compensates that
    // shrink when the stage is too narrow to show two cards; on wider stages
    // the widening gaps remain part of the depth. Drag uses the same step, so
    // what the finger crosses and what the row advances never diverge.
    const away = Math.min(Math.abs(index - activeIndex), DOCK.depth);

    return {
      transform: `translate3d(${index * step}px, ${dockY}px, 0) rotate(0deg) scale(${1 - away * DOCK.recede})`,
      opacity: 1,
    };
  }

  const slot = Math.min(index, deepestSlot);

  return {
    transform: `translate3d(0, ${-slot * REST.rise}px, 0) rotate(${REST.tilt + slot * REST.tiltStep}deg) scale(${1 - slot * REST.shrink})`,
    opacity: index > deepestSlot ? 0 : 1,
  };
}

/**
 * Which card a finished drag lands on, or `activeIndex` when it was too short
 * to commit. Travel is the finger's distance plus a capped slice of its parting
 * speed, so a flick that barely moves still commits, a flick however hard is
 * worth one card, and one long drag can still cross several. Exported so the
 * velocity arithmetic can be exercised without synthesizing a drag.
 */
export function deckLiftSwipeTarget({
  offsetX,
  velocityX,
  step,
  activeIndex,
  count,
}: {
  offsetX: number;
  velocityX: number;
  /** Distance between docked cards. Zero before the deck is measured. */
  step: number;
  activeIndex: number;
  count: number;
}) {
  if (!step) return activeIndex;

  const flick = (velocityX * SWIPE.velocity) / step;
  const cards =
    offsetX / step +
    Math.min(Math.max(flick, -SWIPE.flickReach), SWIPE.flickReach);

  if (Math.abs(cards) < SWIPE.threshold) return activeIndex;

  // Dragging right reveals the card before the active one.
  const towards = cards > 0 ? -1 : 1;
  const reach = Math.max(1, Math.round(Math.abs(cards)));

  return Math.min(Math.max(activeIndex + towards * reach, 0), count - 1);
}

const useIsomorphicLayoutEffect =
  typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;

/**
 * A screen in three layers — a page of summary values, a raised sheet, and a
 * deck of cards between them. Opening a card lifts the whole deck to the top,
 * drops the sheet away, and pulls a full page of detail up behind the cards,
 * which then swipe as a row with the detail following underneath.
 *
 * Why it is built this way: the card the finger touched is the same element
 * that ends up docked at the top — nothing cross-fades, nothing is re-created —
 * so the move reads as *this screen became that card's screen*. The cover rides
 * up behind the cards on the same spring, so the summary values are wiped away
 * by the motion the eye is already following rather than by a scrim.
 *
 * @example
 *   <DeckLift items={cards} sheet={<Sheet />} className="h-dvh">
 *     <Dashboard />
 *   </DeckLift>
 *
 * - The stage is this block's own box, not the viewport, and it is a container:
 *   the card and the reading column widen with it, so the same screen works on a
 *   phone, a tablet and a desktop without a device prop.
 * - Card width is `--deck-lift-card-width`, a clamp that grows with the stage.
 *   The docked row, the swipe step and the detail offset are all measured from
 *   the card it produces, so overriding that one property moves the whole
 *   composition with it.
 * - It clips with `overflow-clip`: a scroll container would let the browser
 *   scroll a focused card into view and drag every layer with it.
 * - The layers under the open cover are `inert`, which contains focus without a
 *   hand-written trap; a card is one element in both states, so focus stays on
 *   whatever was pressed and only Escape has to put it back.
 * - The layers are ordered in the DOM the way the screen reads down — control
 *   row, cards, detail — and stacked with `z-index`, so Tab follows the eye
 *   rather than the paint order.
 * - Every position is measured, so a resize changes the same targets a press
 *   does. Those land instantly: a resize is not a move anyone made.
 * - `prefers-reduced-motion` drops the travel and keeps the fades, so a swap is
 *   still visible without anything crossing the screen.
 * - Cards bring no surface of their own. Give them a shadow through
 *   `cardClassName` or the pile's layers merge.
 */
export function DeckLift({
  items,
  children,
  sheet,
  value,
  defaultValue = null,
  onValueChange,
  pileSize = 2,
  deckLabel = "Cards",
  closeLabel = "Close",
  className,
  ref,
  cardClassName,
  sheetClassName,
  detailClassName,
  ...props
}: DeckLiftProps) {
  const reduceMotion = useReducedMotion() === true;
  const reactId = React.useId();
  const panelId = `${reactId}-panel`;
  // Stable, so the focus helper built on it can be stable too.
  const cardId = React.useCallback(
    (id: string) => `${reactId}-card-${id}`,
    [reactId],
  );
  const { setMeasureRef, sizes } = useElementSizeMap<HTMLElement>();
  const stageNode = React.useRef<HTMLDivElement | null>(null);
  const detailScroller = React.useRef<HTMLDivElement | null>(null);
  const dragged = React.useRef(false);

  const isControlled = value !== undefined;
  const [uncontrolled, setUncontrolled] = React.useState<string | null>(
    defaultValue,
  );
  const openId =
    items.find((item) => item.id === (isControlled ? value : uncontrolled))
      ?.id ?? null;
  const open = openId !== null;

  // A card removed from `items` while it was open leaves the deck closed. The
  // stored id has to go with it, or putting that card back would re-open the
  // deck on its own.
  if (!isControlled && uncontrolled !== null && openId === null) {
    setUncontrolled(null);
  }

  const stageWidth = sizes.stage?.width ?? 0;
  const stageHeight = sizes.stage?.height ?? 0;
  const cardWidth = sizes.card?.width ?? 0;
  const cardHeight = sizes.card?.height ?? 0;
  const step = deckLiftDockStep({ stageWidth, cardWidth });
  const dockY = stageHeight ? DOCK.top - REST.top * stageHeight : 0;
  const deepestSlot = Math.max(0, Math.floor(pileSize) - 1);

  // What the deck last settled on. Every position below is measured, so a target
  // moves on a resize as much as on a press — and on the first render nothing is
  // measured at all. Neither is a move anyone made: without this the deck springs
  // after every ResizeObserver frame, and a deck that starts open springs in from
  // wherever the unmeasured layout put it. Adjusted during render, because the
  // transition this picks has to reach the DOM in the same commit as the target
  // it applies to.
  const [settled, setSettled] = React.useState({
    openId,
    step,
    dockY,
    instant: false,
  });

  const pressed = settled.openId !== openId;
  const measured = settled.step !== step || settled.dockY !== dockY;

  if (pressed || measured) {
    setSettled({ openId, step, dockY, instant: measured && !pressed });
  }

  // A render that changes neither leaves this as it was, which is harmless: with
  // no target to move, nothing animates either way.
  const instant = reduceMotion || settled.instant;

  const stageSpring = instant ? SPRING.still : SPRING.stage;
  const snapSpring = instant ? SPRING.still : SPRING.snap;

  // The detail outlives the open state, so closing never blanks the page
  // mid-flight, and it remembers which way the deck moved so it can leave the
  // same way. Synced in render — an effect would swap it a frame late.
  const [detail, setDetail] = React.useState(() => ({
    id: openId ?? items[0]?.id ?? "",
    direction: 0,
  }));

  if (open && openId !== detail.id) {
    const from = items.findIndex((item) => item.id === detail.id);
    const to = items.findIndex((item) => item.id === openId);

    setDetail({
      id: openId,
      // Opening from rest is not a lateral move: the deck arrives with this card
      // at the front, so its detail belongs there from the first frame rather
      // than sliding in past a card the row was never showing. Only a swipe or
      // an arrow key, which moves the row sideways, gives a swap its direction.
      direction:
        settled.openId !== null && from !== -1 ? (to < from ? -1 : 1) : 0,
    });
  } else if (!open) {
    // The card the detail was showing can disappear from `items`. Fall back
    // without a direction rather than sliding to a neighbour it never had.
    const fallback = items.some((item) => item.id === detail.id)
      ? detail.id
      : (items[0]?.id ?? "");

    if (fallback !== detail.id) setDetail({ id: fallback, direction: 0 });
  }

  const activeIndex = Math.max(
    0,
    items.findIndex((item) => item.id === detail.id),
  );
  const activeItem = items[activeIndex];
  const direction = detail.direction;

  // Reduced motion drops the swap's travel and keeps its hand-off — see `fade`
  // above.
  const swapTravel = reduceMotion ? 0 : DETAIL.swapTravel;

  // The deck's x is a motion value because a drag writes to the same value:
  // after a drag that commits nothing there is no new target for an `animate`
  // prop to re-apply, and `dragSnapToOrigin` would send the deck to its layout
  // origin — the first card — instead of the one being looked at.
  const deckX = useMotionValue(0);
  const deckTarget = open ? -activeIndex * step : 0;
  const deckTargetRef = React.useRef(deckTarget);

  React.useEffect(() => {
    deckTargetRef.current = deckTarget;

    const controls = animate(deckX, deckTarget, snapSpring);
    return () => controls.stop();
  }, [deckTarget, deckX, snapSpring]);

  // The page of detail moves on a swap and at no other time. It is deliberately
  // not wired to the drag: the deck is the layer the finger has hold of, and a
  // page that leans out under every uncommitted quarter-swipe is a paragraph
  // being tugged away from the eye that is reading it. So it holds still until
  // the release decides, and then trades itself for the next one.
  const detailVariants = React.useMemo(() => {
    const travel = step * swapTravel;
    const at = (x: number) => `translate3d(${x}px, 0, 0)`;

    return {
      enter: (towards: number) => ({
        opacity: 0,
        transform: at(towards * travel),
      }),
      center: {
        opacity: 1,
        transform: at(0),
        transition: {
          transform: snapSpring,
          opacity: SPRING.detailArrive,
        },
      },
      exit: (towards: number) => ({
        opacity: 0,
        transform: at(towards * -travel),
        transition: SPRING.detailLeave,
      }),
    };
  }, [snapSpring, step, swapTravel]);

  /**
   * Focus a card by id rather than through a map of refs. Holding that map cost
   * the deck its measurement: a per-card `ref` has to be written inline to
   * close over the card's id, which makes it a new function every render, so
   * React re-attaches it on every commit and re-measures the card it is on.
   * That measurement is `step`, which every position here is built from, so the
   * churn dragged the whole docked row about under the finger. Every card
   * already carries a stable `id`; one lookup is enough.
   */
  const focusCard = React.useCallback(
    (id: string) => {
      const doc = stageNode.current?.ownerDocument ?? document;

      doc.getElementById(cardId(id))?.focus();
    },
    [cardId],
  );

  const setOpenValue = React.useCallback(
    (nextId: string | null) => {
      const next = items.find((item) => item.id === nextId) ?? null;

      if (!isControlled) setUncontrolled(next?.id ?? null);

      onValueChange?.(next?.id ?? null, next);
    },
    [isControlled, items, onValueChange],
  );

  const select = React.useCallback(
    (index: number, focus = false) => {
      const next = items[Math.min(Math.max(index, 0), items.length - 1)];

      if (!next || next.id === detail.id) return;

      setOpenValue(next.id);

      if (focus) focusCard(next.id);
    },
    [detail.id, focusCard, items, setOpenValue],
  );

  // The detail is one scroller every card reuses, so a card that opens after a
  // long one would otherwise arrive already scrolled. A layout effect, so the
  // swap and the reset land in the same frame, and never while closing, where the
  // jump would be on screen.
  useIsomorphicLayoutEffect(() => {
    if (!open || !detailScroller.current) return;

    detailScroller.current.scrollTop = 0;
  }, [open, detail.id]);

  const close = React.useCallback(() => {
    // The open card is the element that was pressed to get here, so returning
    // focus to it is the only focus move this block makes.
    focusCard(detail.id);
    setOpenValue(null);
  }, [detail.id, focusCard, setOpenValue]);

  // Read through a ref: with an inline `onValueChange`, `close` changes identity
  // on every parent render, and the Escape listener must not re-bind that often.
  const closeRef = React.useRef(close);

  React.useEffect(() => {
    closeRef.current = close;
  });

  React.useEffect(() => {
    if (!open) return;

    // Clicking dead space inside a page moves focus to the body, so this has to
    // be the document rather than the stage. `defaultPrevented` yields to a
    // popover or combobox inside the detail that took Escape first, and marking
    // the event afterwards lets a dialog this block sits inside skip it too.
    const doc = stageNode.current?.ownerDocument ?? document;

    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key !== "Escape" || event.defaultPrevented) return;

      event.preventDefault();
      closeRef.current();
    };

    doc.addEventListener("keydown", onKeyDown);
    return () => doc.removeEventListener("keydown", onKeyDown);
  }, [open]);

  const onDeckKeyDown = (event: React.KeyboardEvent) => {
    if (!open) return;

    const to = {
      ArrowRight: activeIndex + 1,
      ArrowLeft: activeIndex - 1,
      Home: 0,
      End: items.length - 1,
    }[event.key];

    if (to === undefined) return;

    event.preventDefault();
    select(to, true);
  };

  const onDragEnd = (_event: unknown, info: PanInfo) => {
    const target = deckLiftSwipeTarget({
      offsetX: info.offset.x,
      velocityX: info.velocity.x,
      step,
      activeIndex,
      count: items.length,
    });

    // Every release settles the row, not only an uncommitted one: nothing else
    // would pull it off the offset the finger left it at.
    //
    // Where it settles *to* is the whole difference. Uncontrolled, this
    // component decides which card wins, so the row is sent straight to it and
    // the retarget above lands on the same value — one continuous spring. Sent
    // to the card being left instead, as it was, the row travels back under the
    // finger's own direction for the render it takes to commit and only then
    // turns around: a kick on every release that crosses a card, and the whole
    // reason a swipe felt like it jumped.
    //
    // Controlled, that shortcut is not available. The owner may decline the
    // swipe, and a row already on its way to a card it was never given would be
    // showing one screen under another's detail — so it goes back to the card on
    // screen and waits to be told, which is the one case where the kick is the
    // honest answer.
    animate(
      deckX,
      isControlled ? deckTargetRef.current : -target * step,
      snapSpring,
    );

    select(target);

    // The click that follows a drag has to be swallowed, but the flag has to be
    // gone before the next keyboard activation, which arrives with no pointer
    // event to clear it.
    requestAnimationFrame(() => {
      dragged.current = false;
    });
  };

  const setStageRef = React.useCallback(
    (node: HTMLDivElement | null) => {
      stageNode.current = node;
      setMeasureRef("stage")(node);

      if (typeof ref === "function") {
        ref(node);
      } else if (ref) {
        ref.current = node;
      }
    },
    [ref, setMeasureRef],
  );

  return (
    <div
      ref={setStageRef}
      data-slot="deck-lift"
      data-state={open ? "open" : "closed"}
      className={cn(
        "@container relative isolate overflow-clip bg-background [contain:layout_paint]",
        // The one width the composition is built from: it grows with the stage
        // and stops at a card-sized cap, so a phone, a tablet and a desktop all
        // get a card rather than a stretched one. Every other measurement is
        // taken from the card this produces. (A `@container` cannot query
        // itself, so this is a clamp rather than container-query breakpoints.)
        "[--deck-lift-card-width:clamp(15rem,calc(100%-4.5rem),24rem)]",
        // How far from the middle a card is gone. The row stays full width and
        // fully draggable; this only says how much of it is being presented.
        "[--deck-lift-focus:var(--deck-lift-card-width)]",
        className,
      )}
      {...props}
    >
      {/* The page. It never moves; it is covered. */}
      <div
        data-slot="deck-lift-page"
        inert={open}
        aria-hidden={open || undefined}
        className="absolute inset-0 z-0"
      >
        {children}
      </div>

      {/* The control row. Its own layer on the cover's spring, so the close
          button can be read — and reached — before the cards it sits above:
          `z-index` orders what is painted, the DOM orders what Tab visits. */}
      <motion.div
        data-slot="deck-lift-controls"
        inert={!open}
        aria-hidden={open ? undefined : true}
        initial={false}
        animate={{
          transform: open
            ? "translate3d(0, 0, 0)"
            : stageHeight
              ? `translate3d(0, ${stageHeight}px, 0)`
              : "translate3d(0, 100dvh, 0)",
        }}
        transition={stageSpring}
        style={{ height: DOCK.top }}
        className="pointer-events-none absolute inset-x-0 top-0 z-40"
      >
        <div className="flex size-full items-center px-3">
          <button
            type="button"
            onClick={close}
            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"
          >
            <svg
              aria-hidden="true"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
              className="size-4"
            >
              <path d="M18 6 6 18M6 6l12 12" />
            </svg>
            <span className="sr-only">{closeLabel}</span>
          </button>
        </div>
      </motion.div>

      {/* The deck: a pile at rest, a swipeable row once open.

          Two boxes, because the mask has to hold still. It lives on this frame
          rather than on the row, which is translated by `x` — a mask travels
          with the box it is set on, so one applied to the row would slide off
          centre along with it. */}
      <div
        data-slot="deck-lift-focus"
        data-swipe-navigation="ignore"
        className={cn(
          // Fill the stage vertically. The cards travel from the resting pile
          // to the dock, but the desktop mask must not travel or keep the
          // resting pile's short border box: masked overflow outside that box
          // is transparent, which made the entire docked deck disappear.
          "pointer-events-none absolute inset-0 z-20",
          // Only once the stage has more width than the composition wants. On a
          // phone the card already fills it and the neighbours see themselves
          // off; from here up the stage keeps growing while the card stops at
          // its cap, so the spare width turns into *more cards* rather than a
          // bigger one — three of them at desktop, none of which says which one
          // the detail below belongs to. Every other layer here caps its own
          // width for the same reason; this is the deck's version of that.
          "@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)))]",
        )}
      >
        <motion.div
          data-slot="deck-lift-deck"
          role={open ? "tablist" : undefined}
          aria-label={open ? deckLabel : undefined}
          aria-orientation={open ? "horizontal" : undefined}
          drag={open && items.length > 1 ? "x" : false}
          dragElastic={SWIPE.elastic}
          dragMomentum={false}
          dragConstraints={{ left: -(items.length - 1) * step, right: 0 }}
          onPointerDownCapture={() => {
            dragged.current = false;
          }}
          onDragStart={() => {
            dragged.current = true;
          }}
          onDragEnd={onDragEnd}
          onKeyDown={onDeckKeyDown}
          initial={false}
          style={{ x: deckX, top: `${REST.top * 100}%` }}
          className="absolute inset-x-0 grid justify-items-center"
        >
          {items.map((item, index) => {
            const active = open && item.id === detail.id;
            const waiting = !open && index > deepestSlot;

            return (
              <motion.button
                key={item.id}
                id={cardId(item.id)}
                // The deck's geometry is read off the first rendered card.
                // `setMeasureRef` caches per key, so this is the same function
                // every render and React leaves the subscription alone.
                ref={index === 0 ? setMeasureRef("card") : undefined}
                type="button"
                aria-label={item.label}
                role={open ? "tab" : undefined}
                aria-selected={open ? active : undefined}
                aria-controls={open ? panelId : undefined}
                aria-expanded={open ? undefined : false}
                tabIndex={open && !active ? -1 : 0}
                inert={waiting}
                data-slot="deck-lift-card"
                data-active={active || undefined}
                onClick={() => {
                  if (dragged.current) return;
                  if (open) select(index);
                  else setOpenValue(item.id);
                }}
                initial={false}
                animate={cardState({
                  open,
                  index,
                  activeIndex,
                  deepestSlot,
                  step,
                  dockY,
                })}
                transition={{
                  ...stageSpring,
                  opacity: reduceMotion
                    ? SPRING.fade
                    : open
                      ? SPRING.arrive
                      : SPRING.leave,
                }}
                style={{
                  zIndex:
                    items.length -
                    (open ? Math.abs(index - activeIndex) : index),
                  touchAction: open ? "pan-y" : undefined,
                }}
                className={cn(
                  "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",
                  cardClassName,
                )}
              >
                {item.face}
              </motion.button>
            );
          })}
        </motion.div>
      </div>

      {/* The cover, rising behind the cards to wipe the page away. */}
      <motion.div
        data-slot="deck-lift-cover"
        inert={!open}
        aria-hidden={open ? undefined : true}
        initial={false}
        animate={{ y: open ? "0%" : "100%" }}
        transition={stageSpring}
        className="absolute inset-0 z-10 flex flex-col bg-background"
      >
        {/* The control row and the floating deck both sit over this. */}
        <div
          aria-hidden="true"
          className="shrink-0"
          style={{ height: DOCK.top + cardHeight + DOCK.detailGap }}
        />

        <div
          id={panelId}
          role={open ? "tabpanel" : undefined}
          aria-labelledby={
            open && activeItem ? cardId(activeItem.id) : undefined
          }
          data-slot="deck-lift-detail"
          className={cn("flex min-h-0 flex-1 flex-col py-2", detailClassName)}
        >
          {/* Padding stays on this frame so it does not scroll away. */}
          <div
            ref={detailScroller}
            className="min-h-0 flex-1 overflow-x-clip overflow-y-auto"
          >
            <div className="mx-auto grid w-full max-w-2xl @5xl:max-w-3xl">
              <AnimatePresence initial={false} custom={direction}>
                {activeItem ? (
                  <motion.div
                    key={activeItem.id}
                    custom={direction}
                    variants={detailVariants}
                    initial="enter"
                    animate="center"
                    exit="exit"
                    className="min-w-0 [grid-area:1/1]"
                  >
                    {activeItem.detail}
                  </motion.div>
                ) : null}
              </AnimatePresence>
            </div>
          </div>
        </div>
      </motion.div>

      {/* The sheet, dropping out from over the pile. */}
      {sheet ? (
        <motion.div
          data-slot="deck-lift-sheet"
          inert={open}
          aria-hidden={open || undefined}
          initial={false}
          animate={{ y: open ? "100%" : "0%" }}
          transition={stageSpring}
          className={cn(
            "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",
            sheetClassName,
          )}
        >
          {sheet}
        </motion.div>
      ) : null}
    </div>
  );
}

Installation