Deck
Browse the catalog

Build / Organisms

CardPile

  • organism
  • play-fn

A pile of PersonCards you flip through by swiping — the physical-deck metaphor made literal, for browsing a person's saved or captured cards.

Only the front card is real to assistive tech; the peeking cards behind it are decorative (aria-hidden + inert) since they duplicate content the front card will present once it's on top. Every gesture has a non-swipe equivalent: visible Previous/Next buttons, and Arrow Left/Right when focus is anywhere inside the pile — swiping is an accelerator, not the only way in. A tap that doesn't move (a click on the front card's own content, e.g. a "Share" button) is left alone; only motion past the drag threshold is treated as a swipe.

The pile also decides which way up its cards sit — portrait on a phone, landscape from sm up — and publishes that as data-card-orientation for the cards to lay themselves out against. It is the pile's call rather than the card's because it is a fact about the space the cards are being shown in, not about any one of them.

import { CardPile } from '@meetcard/deck'

Example

<CardPile label="Ada's saved cards"> <PersonCard name="Ada Lovelace" title="Head of Partnerships" /> <PersonCard name="Grace Hopper" title="Principal Engineer" /> </CardPile>

Stories

Every preview below is the real component, rendered in this page from the same story Storybook runs. The width control resizes each preview's container; CSS media queries still follow the browser window, so breakpoint behaviour is worth checking in Storybook.

Three cards piled, front one fully readable with the rest peeking behind.

Default
Source
const Default = () => <CardPile label="Ada's saved cards" onActiveIndexChange={fn()}>
    <PersonCard
        name="Ada Lovelace"
        title="Head of Partnerships"
        company="MeetCard"
        tagline="Let's find the overlap."
        footer={
          <Button variant="secondary" size="sm">
            Share
          </Button>
        } />
    <PersonCard name="Ada Lovelace" title="Analytical Engine" company="Side project" />
    <PersonCard name="Ada Lovelace" title="Countess of Lovelace" />
</CardPile>;

Swiping the front card past the drag threshold advances the pile — the front card animates off and the next one takes its place. Simulated here via pointer events rather than a real touch gesture, since jsdom/Chromium test runners don't synthesize touch drags.

Swipe Advances
Source
const SwipeAdvances = () => <CardPile label="Ada's saved cards" onActiveIndexChange={fn()}>
    <PersonCard name="Ada Lovelace" title="Card one" />
    <PersonCard name="Grace Hopper" title="Card two" />
    <PersonCard name="Katherine Johnson" title="Card three" />
</CardPile>;

A small drag that doesn't clear the threshold springs back — it isn't mistaken for a swipe, and a tap on the front card's own content (like its Share button) still works normally.

Small Drag Springs Back
Source
const SmallDragSpringsBack = () => <CardPile label="Ada's saved cards" onActiveIndexChange={fn()}>
    <PersonCard name="Ada Lovelace" title="Card one" />
    <PersonCard name="Grace Hopper" title="Card two" />
</CardPile>;

Swiping right — the opposite direction from SwipeAdvances — goes backward instead, wrapping to the last card. Real Chromium only: jsdom's PointerEvent support can't reliably drive a drag.

Swipe Right Goes To Previous
Source
const SwipeRightGoesToPrevious = () => <CardPile label="Ada's saved cards" onActiveIndexChange={fn()}>
    <PersonCard name="Ada Lovelace" title="Card one" />
    <PersonCard name="Grace Hopper" title="Card two" />
    <PersonCard name="Katherine Johnson" title="Card three" />
</CardPile>;

Previous/Next buttons and Arrow Left/Right are full equivalents to swipe.

Keyboard And Buttons
Source
const KeyboardAndButtons = () => <CardPile label="Ada's saved cards" onActiveIndexChange={fn()}>
    <PersonCard name="Ada Lovelace" title="Card one" />
    <PersonCard name="Grace Hopper" title="Card two" />
    <PersonCard name="Katherine Johnson" title="Card three" />
</CardPile>;

Only the front card is visible to assistive tech — the peeking cards behind it are decorative duplicates of content the front card will show once it cycles to the top.

Back Cards Are Decorative
Source
const BackCardsAreDecorative = () => <CardPile label="Ada's saved cards" onActiveIndexChange={fn()}>
    <PersonCard name="Ada Lovelace" title="Card one" />
    <PersonCard name="Grace Hopper" title="Card two" />
    <PersonCard name="Katherine Johnson" title="Card three" />
</CardPile>;

More cards than fit as physical depth collapse into a count badge.

Overflow Badge
Source
const OverflowBadge = () => <CardPile label="Ada's saved cards" onActiveIndexChange={fn()} maxVisible={3}>
    <PersonCard name="Ada Lovelace" title="One" />
    <PersonCard name="Grace Hopper" title="Two" />
    <PersonCard name="Katherine Johnson" title="Three" />
    <PersonCard name="Margaret Hamilton" title="Four" />
    <PersonCard name="Radia Perlman" title="Five" />
</CardPile>;

A single card renders cleanly — no controls, since there's nothing to flip to.

Single Card
Source
const SingleCard = () => <CardPile label="Ada's saved cards" onActiveIndexChange={fn()}>
    <PersonCard name="Ada Lovelace" title="Head of Partnerships" />
</CardPile>;

Stood on its short edge — how the pile lays itself out on a phone, and what orientation="responsive" (the default) resolves to below sm. Pinned here so the portrait layout is reviewable at any canvas size.

The card is the same object turned: 4/7 instead of 7/4, same content, re-ordered so the eye runs down it — portrait, ways to reach them, name.

Portrait
Source
const Portrait = () => <CardPile
    label="Ada's saved cards"
    onActiveIndexChange={fn()}
    orientation="portrait">
    <PersonCard
        name="Ada Lovelace"
        title="Head of Partnerships"
        company="MeetCard"
        location="Boulder, Colorado"
        tagline="Let's find the overlap."
        privateNote={{ hasContent: true }}
        footer={
          <>
            <Button size="sm">Book with me</Button>
            <Button variant="secondary" size="sm">
              Exchange cards
            </Button>
          </>
        } />
    <PersonCard name="Grace Hopper" title="Principal Engineer" />
    <PersonCard name="Katherine Johnson" title="Research Mathematician" />
</CardPile>;

Every card in the pile is the same object: identical box, at roughly a US business card's 3.5x2. Measured rather than asserted in prose, because the pile only reads as a pile while it holds — content-driven heights had one person's card 75px shorter than the next, and no amount of offsetting recovers from that.

Uniform Cards
Source
const UniformCards = () => <CardPile label="Ada's saved cards" onActiveIndexChange={fn()}>
    <PersonCard
        name="Ada Lovelace"
        title="Head of Partnerships"
        company="MeetCard"
        location="Boulder, Colorado"
        tagline="Let's find the overlap."
        footer={
          <Button variant="secondary" size="sm">
            Share
          </Button>
        } />
    {/* Deliberately sparse — the card must not shrink to fit less. */}
    <PersonCard name="Ada Lovelace" />
    <PersonCard
        name="Augusta Ada King-Noel, Countess of Lovelace"
        title="Head of Strategic Partnerships and Developer Relations"
        company="MeetCard" />
</CardPile>;

Props

PropTypeDefaultDescription
childrenrequiredReactNode—PersonCard elements, front-to-back — the first child starts on top.
activeIndexnumber—Controlled position. Supply it and the pile stops keeping its own, leaving onActiveIndexChange as the only way it asks to move — for a surface that also selects cards from somewhere else, a list beside the pile or a card it has just created. Omit it for the uncontrolled behaviour seeded by defaultActiveIndex.
defaultActiveIndexnumber0Uncontrolled starting position. Default 0.
labelstring—Accessible name for the pile, e.g. "Ada's saved cards".
maxVisiblenumber3How many cards render as physical depth (the front card plus peeking layers behind it). Extra cards beyond this collapse into a "+N" badge rather than bloating the DOM. Default 3.
onActiveIndexChange(index: number) => void—Fires after a swipe, button press, or arrow key completes.
orientationResolvedCardOrientation | 'responsive''responsive'Which way up the cards sit. Default responsive — portrait on a phone, landscape from sm up. Pin it when the surface around the pile has already committed to a shape.

Also accepts everything from Omit, so id, data-* and event handlers pass straight through.

Open in Storybookfor controls, the a11y panel and the interaction runner.