Build / Organisms
CardPile
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
Three cards piled, front one fully readable with the rest peeking behind.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | PersonCard elements, front-to-back — the first child starts on top. |
activeIndex | number | — | 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. |
defaultActiveIndex | number | 0 | Uncontrolled starting position. Default 0. |
label | string | — | Accessible name for the pile, e.g. "Ada's saved cards". |
maxVisible | number | 3 | How 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. |
orientation | ResolvedCardOrientation | '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.