StackCard

A pile of cards, taken one at a time by throwing the top one off.

For a queue of things each answered with one decision and then gone — a review queue, a set of flashcards, an inbox of suggestions. The gesture is the answer, which is what makes it quicker than a list of rows with buttons on them.

It shows one card and hides the rest, so it is the wrong shape for anything the reader has to compare, skim or come back to. Give the deck a height. The cards are laid over each other and have no height of their own; the pile takes what is left of the root after anything laid out under it.

For a run of slides the reader browses rather than disposes of, use Carousel. For one row's actions inside a list, use Swipe.

Installation

StackCard ships with the library — no separate install.

import { useRef, useState } from 'react';
import { StackCard, Avatar, Badge, Button, CheckIcon, Chip, Dialog, RotateCcwIcon, RotateCwIcon, StackCardHandle, Text, TrashIcon, XIcon, useStackCard } from 'panelui-native';
import { View } from 'react-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add stack-card

Usage

<StackCard className="h-[460px]" onSwipe={(direction, index) => decide(people[index], direction)}>
  <StackCard.Stamp direction="right" color="success">Yes</StackCard.Stamp>
  <StackCard.Stamp direction="left" color="destructive">No</StackCard.Stamp>

  {people.map((person) => (
    <StackCard.Card key={person.id} className="justify-between p-6">
      <Text size="2xl" weight="semibold">{person.name}</Text>
      <Text size="sm" muted>{person.role}</Text>
    </StackCard.Card>
  ))}

  <StackCard.Empty>
    <Text size="sm" muted>Nobody left</Text>
  </StackCard.Empty>

  <StackCard.Actions>
    <StackCard.Action action="left" color="destructive" icon={<XIcon />} label="Pass" />
    <StackCard.Action action="undo" size="sm" icon={<RotateCcwIcon />} />
    <StackCard.Action action="right" color="success" icon={<CheckIcon />} label="Keep" />
  </StackCard.Actions>
</StackCard>

Composition

<StackCard>
  <StackCard.Stamp direction="right" />
  <StackCard.Stamp direction="left" />

  <StackCard.Card />
  <StackCard.Card />

  <StackCard.Empty />

  <StackCard.Actions>
    <StackCard.Action action="left" />
    <StackCard.Action action="undo" />
    <StackCard.Action action="right" />
  </StackCard.Actions>
</StackCard>

Order does not matter. The parts are recognised by type, so the cards, the stamps and the empty state can be written in whatever order reads best, and the deck lifts each one into the place it belongs.

Anything that is not one of the parts is laid out under the pile, in flow. That is how a counter, a progress bar or a row of your own buttons goes beside a deck and still reaches useStackCard().

Stamps are declared on the deck rather than inside a card. There is one set of them for the whole run, and they are drawn on whichever card is currently on top — writing them into every card would be the same two elements repeated once per item, each with its own subscription to the same drag.

Examples

Driven from outside

A ref gives you swipe, undo and reset, for a deck answered from a toolbar rather than from the card.

A programmatic swipe is the same animation as a thrown one, so the two are indistinguishable on the screen. Pair it with disabled where the card itself should not take a gesture at all — the deck still looks and animates exactly the same, it simply does not answer a finger.

const deck = useRef<StackCardHandle>(null);

<StackCard ref={deck} className="h-[140px]" disabled>
  {places.map((place) => (
    <StackCard.Card key={place.name} className="justify-center gap-1 p-4">
      <Text weight="semibold">{place.name}</Text>
      <Text size="sm" muted>{place.kind}</Text>
    </StackCard.Card>
  ))}
  <StackCard.Empty>
    <Text size="sm" muted>Nothing left — put them back.</Text>
  </StackCard.Empty>
</StackCard>

<View className="flex-row gap-2">
  <Button variant="outline" className="flex-1" onPress={() => deck.current?.swipe('left')}>Send left</Button>
  <Button variant="outline" className="flex-1" onPress={() => deck.current?.undo()}>Undo</Button>
  <Button variant="outline" className="flex-1" onPress={() => deck.current?.reset()}>Reset</Button>
</View>

A counter beside the deck

useStackCard() reads the deck from anything inside it, so a count, a progress bar or a button of your own does not need the state duplicated outside.

It also returns release — a shared value running 0 to 1 as the top card is carried toward leaving — for something beside the deck that should move with the drag rather than start a second animation next to it.

function DeckProgress() {
  const { index, count, remaining } = useStackCard();
  return (
    <View className="flex-row items-center justify-between pt-4">
      <Text size="sm" muted>
        {remaining > 0 ? `${remaining} of ${count} left` : 'All reviewed'}
      </Text>
      <Text size="sm" weight="medium">{index} decided</Text>
    </View>
  );
}

<StackCard className="h-[420px]">
  {items.map((item) => <StackCard.Card key={item.id}>{/* … */}</StackCard.Card>)}
  <DeckProgress />
</StackCard>

How hard a card has to be thrown

threshold is how far a card must be carried for a release to send it away, as a fraction of the card. Momentum counts toward it, so a flick clears it without travelling that far — a short fast throw and a long slow drag both work, which is the difference between a deck that feels light and one that feels heavy.

Raise it where a decision is expensive and a card brushed by a thumb should not commit. Lower it for a queue somebody is working through at speed.

{/* A deliberate throw, for decisions worth being sure about. */}
<StackCard className="h-[420px]" threshold={0.5}>
  {items.map((item) => <StackCard.Card key={item.id}>{/* … */}</StackCard.Card>)}
</StackCard>

{/* A light one, for a queue being cleared quickly. */}
<StackCard className="h-[420px]" threshold={0.18}>
  {items.map((item) => <StackCard.Card key={item.id}>{/* … */}</StackCard.Card>)}
</StackCard>

Versions

A hiring queue

The case the component exists for: one decision per card, and the card is gone once it is made.

The stamps name the two answers before either is committed, and each reaches full strength exactly where letting go would send the card — so a solid stamp and the haptic tick say the same thing twice. That repetition is deliberate: haptics are off system-wide for a lot of people and silent on most Android hardware.

The buttons are not a fallback. They are how the deck is reachable with a screen reader, and they are what people reach for on the card they are unsure about, because a throw looks irreversible in a way a tap does not.

const [shortlist, setShortlist] = useState([]);

<StackCard
  className="h-[460px]"
  directions={['left', 'right']}
  directionLabels={{ left: 'Pass', right: 'Shortlist' }}
  onSwipe={(direction, index) => {
    if (direction === 'right') setShortlist((current) => [...current, candidates[index].name]);
  }}
>
  <StackCard.Stamp direction="right" color="success">Shortlist</StackCard.Stamp>
  <StackCard.Stamp direction="left" color="destructive">Pass</StackCard.Stamp>

  {candidates.map((candidate) => (
    <StackCard.Card key={candidate.name} className="justify-between p-6">
      <View className="gap-4">
        <View className="flex-row items-start justify-between gap-3">
          <Avatar size="lg" fallback={initials(candidate.name)} />
          <Badge variant="secondary">{candidate.years}</Badge>
        </View>
        <View className="gap-1">
          <Text size="2xl" weight="semibold">{candidate.name}</Text>
          <Text size="sm" muted>{candidate.role}</Text>
          <Text size="sm" muted>{candidate.where}</Text>
        </View>
      </View>

      <Text size="sm">{candidate.note}</Text>

      <View className="gap-3">
        <View className="border-t border-border" />
        <View className="flex-row flex-wrap gap-2">
          {candidate.tags.map((tag) => <Chip key={tag} size="sm">{tag}</Chip>)}
        </View>
      </View>
    </StackCard.Card>
  ))}

  <StackCard.Empty>
    <Text size="lg" weight="semibold">{shortlist.length} shortlisted</Text>
  </StackCard.Empty>

  <StackCard.Actions>
    <StackCard.Action action="left" color="destructive" icon={<XIcon />} label="Pass" />
    <StackCard.Action action="undo" size="sm" icon={<RotateCcwIcon />} />
    <StackCard.Action action="right" color="success" icon={<CheckIcon />} label="Shortlist" />
  </StackCard.Actions>
  <DeckProgress />
</StackCard>

Flashcards

Up and down instead of left and right, set with directions.

layout="flat" draws nothing behind the top card. The next one waits exactly where the top card is and is uncovered as it leaves, which is what you want when the card is full-bleed and a peeking edge under it would only be clutter.

A direction left out of directions still follows the finger a little and springs back, rather than refusing to move — a card that does not budge reads as a frozen screen rather than as an axis that is not offered.

<StackCard
  className="h-[420px]"
  layout="flat"
  directions={['up', 'down']}
  directionLabels={{ up: 'Knew it', down: 'Show me again' }}
>
  <StackCard.Stamp direction="up" color="success">Knew it</StackCard.Stamp>
  <StackCard.Stamp direction="down" color="warning">Again</StackCard.Stamp>

  {deck.map((card) => (
    <StackCard.Card key={card.term} className="items-center justify-center gap-4 p-8">
      <Text size="xs" weight="medium" muted className="uppercase tracking-widest">Term</Text>
      <Text size="3xl" weight="semibold" className="text-center">{card.term}</Text>
      <View className="w-12 border-t border-border" />
      <Text size="sm" muted className="text-center">{card.gloss}</Text>
    </StackCard.Card>
  ))}

  <StackCard.Actions>
    <StackCard.Action action="down" color="warning" icon={<RotateCwIcon />} label="Again" />
    <StackCard.Action action="up" color="success" icon={<CheckIcon />} label="Knew it" />
  </StackCard.Actions>
</StackCard>

A hand of cards

layout="fan" turns each card behind the top one a few degrees, alternately, and splays it sideways rather than stepping it down.

The side a card leans is fixed to its position in the run rather than to its depth, so the fan does not re-deal itself every time a card leaves.

depth is how many are drawn behind the top one. Two is a pile and five is a mess; three is about as far as a fan goes before the cards at the back stop being distinguishable from the edge of the one in front.

<StackCard className="h-[440px]" layout="fan" depth={3}>
  <StackCard.Stamp direction="right" color="primary">Save</StackCard.Stamp>
  <StackCard.Stamp direction="left">Skip</StackCard.Stamp>

  {places.map((place) => (
    <StackCard.Card key={place.name} className="justify-between p-6">
      <View className="flex-row items-start justify-between gap-3">
        <Badge variant="outline">{place.kind}</Badge>
        <Text size="sm" weight="medium" muted>{place.price}</Text>
      </View>
      <View className="gap-2">
        <Text size="2xl" weight="semibold">{place.name}</Text>
        <Text size="sm" muted>{place.detail}</Text>
      </View>
    </StackCard.Card>
  ))}
</StackCard>

Four ways out

A card can go four ways, each with its own stamp and its own label for a screen reader.

A diagonal throw goes where it was thrown hardest, not to whichever direction happens to be checked first: every accepted direction is measured against the reach on its own axis, and the furthest one wins.

Four answers is the practical ceiling. Past that the gesture stops being one decision and becomes a menu the reader has to aim at.

<StackCard
  className="h-[420px]"
  directions={['left', 'right', 'up', 'down']}
  directionLabels={{ left: 'Archive', right: 'Keep', up: 'Pin', down: 'Snooze' }}
>
  <StackCard.Stamp direction="left">Archive</StackCard.Stamp>
  <StackCard.Stamp direction="right" color="success">Keep</StackCard.Stamp>
  <StackCard.Stamp direction="up" color="primary">Pin</StackCard.Stamp>
  <StackCard.Stamp direction="down" color="warning">Snooze</StackCard.Stamp>

  {inbox.map((mail) => (
    <StackCard.Card key={mail.subject} className="justify-between p-6">
      <View className="gap-3">
        <View className="flex-row items-center gap-3">
          <Avatar size="sm" fallback={initials(mail.from)} />
          <Text size="sm" weight="medium">{mail.from}</Text>
        </View>
        <Text size="xl" weight="semibold">{mail.subject}</Text>
        <Text size="sm" muted>{mail.body}</Text>
      </View>
    </StackCard.Card>
  ))}

  <StackCard.Empty>
    <Text size="lg" weight="semibold">Inbox clear</Text>
  </StackCard.Empty>
</StackCard>

Asking before it goes

Hold index and the deck only moves when you say so.

A left throw opens the dialog and the owner leaves index where it is. The card has already gone by the time the request arrives, so declining it brings the card back, flown in from the side it left by. Confirming throws it out again with swipe.

onSwipe fires before onIndexChange, so read which way the card went from a ref rather than from state. Both fire in the same tick, and state set in onSwipe is not in onIndexChange's closure yet — checking it there accepts every request.

Use it wherever a decision has a consequence worth confirming — a cancellation, a deletion, anything that spends money. For decisions that do not, let the deck keep its own index; a confirmation on every card turns a fast queue into a slow one.

const deck = useRef(null);
const [index, setIndex] = useState(0);
const [pending, setPending] = useState(null);
const thrown = useRef(null);
const confirmed = useRef(false);

<StackCard
  ref={deck}
  className="h-[420px]"
  index={index}
  directionLabels={{ left: 'Cancel it', right: 'Keep it' }}
  onSwipe={(direction, at) => {
    thrown.current = direction;
    if (direction === 'left' && !confirmed.current) setPending(at);
  }}
  onIndexChange={(next) => {
    // Only a keep advances the deck on its own.
    if (thrown.current === 'left' && !confirmed.current) return;
    confirmed.current = false;
    setIndex(next);
  }}
>
  <StackCard.Stamp direction="left" color="destructive">Cancel</StackCard.Stamp>
  <StackCard.Stamp direction="right" color="success">Keep</StackCard.Stamp>

  {subscriptions.map((item) => (
    <StackCard.Card key={item.name} className="justify-between p-6">
      <Text size="2xl" weight="semibold">{item.name}</Text>
      <Text size="3xl" weight="bold">{item.cost}</Text>
      <Text size="sm" muted>{item.use}</Text>
    </StackCard.Card>
  ))}
</StackCard>

<Dialog open={pending !== null} onOpenChange={(open) => !open && setPending(null)}>
  <Dialog.Content>
    <Dialog.Title>Cancel {subscriptions[pending]?.name}?</Dialog.Title>
    <Dialog.Description>It stays active until the end of the current period.</Dialog.Description>
    <Dialog.Footer>
      <Button variant="outline" onPress={() => setPending(null)}>Keep it</Button>
      <Button
        variant="destructive"
        onPress={() => {
          setPending(null);
          confirmed.current = true;
          deck.current?.swipe('left');
        }}
      >
        Cancel it
      </Button>
    </Dialog.Footer>
  </Dialog.Content>
</Dialog>

How the pile is arranged

layout decides what the cards behind the top one do. stack steps them down and shrinks them; fan turns them alternately and splays them sideways; flat hides them entirely.

All three keep the same rule underneath: a card behind climbs toward the top position as the card in front of it is carried away, so by the time the top card commits the next one has already arrived. That is why advancing the deck moves nothing on the screen.

{(['stack', 'fan', 'flat']).map((layout) => (
  <StackCard key={layout} className="h-[140px]" layout={layout}>
    {places.map((place) => (
      <StackCard.Card key={place.name} className="justify-center gap-1 p-4">
        <Text weight="semibold">{place.name}</Text>
        <Text size="sm" muted>{place.detail}</Text>
      </StackCard.Card>
    ))}
  </StackCard>
))}

Variants

color

  • default (default)
  • primary
  • success
  • warning
  • info
  • destructive
{/* `color` is a StackCard.Stamp and StackCard.Action prop. Both take the
    status colours at full strength: a stamp is only on the screen for the
    moment it appears, and a tint of it over a card is a smudge. */}
<StackCard className="h-[420px]">
  <StackCard.Stamp direction="right" color="success">Keep</StackCard.Stamp>
  <StackCard.Stamp direction="left" color="destructive">Delete</StackCard.Stamp>

  {items.map((item) => (
    <StackCard.Card key={item.id} className="justify-center p-6">
      <Text>{item.title}</Text>
    </StackCard.Card>
  ))}

  <StackCard.Actions>
    <StackCard.Action action="left" color="destructive" icon={<TrashIcon />} />
    <StackCard.Action action="right" color="success" icon={<CheckIcon />} />
  </StackCard.Actions>
</StackCard>

direction

  • left
  • right (default)
  • up
  • down
{/* `direction` says which answer a stamp is for, and puts it in the corner
    the card is being pulled away from — which is also the corner the thumb is
    not over. The buttons take `action`, which also accepts `undo`. */}
<StackCard className="h-[420px]" directions={['left', 'right', 'up', 'down']}>
  <StackCard.Stamp direction="left">Archive</StackCard.Stamp>
  <StackCard.Stamp direction="right" color="success">Keep</StackCard.Stamp>
  <StackCard.Stamp direction="up" color="primary">Pin</StackCard.Stamp>
  <StackCard.Stamp direction="down" color="warning">Snooze</StackCard.Stamp>
  {mail.map((item) => <StackCard.Card key={item.id}>{/* … */}</StackCard.Card>)}
</StackCard>

size

  • sm
  • md
  • lg
{/* `size` is a StackCard.Action prop. The glyph is sized with the button,
    so a row of them reads as a set without every call site saying so. */}
<StackCard.Actions>
  <StackCard.Action action="left" size="lg" color="destructive" icon={<XIcon />} />
  <StackCard.Action action="undo" size="sm" icon={<RotateCcwIcon />} />
  <StackCard.Action action="right" size="lg" color="success" icon={<CheckIcon />} />
</StackCard.Actions>

API Reference

StackCard

PropTypeDefaultDescription
indexnumberWhich card is on top, when the caller holds it. Leave unset to let the deck keep its own. A controlled deck that declines a request stays where it is and the thrown card comes back, so this is also how a decision is confirmed before it is taken.
defaultIndexnumber0Which card an uncontrolled deck starts on.
onIndexChange(index: number) => voidFires whenever the deck asks to move, with the index it is asking for.
onSwipe(direction: StackCardDirection, index: number) => voidFires when a card leaves, with the way it went and the index it was at. It fires before onIndexChange asks for the next index. Not called by undo — the index going back is what reports that.
onEmpty() => voidFires once when the last card leaves.
directionsreadonly StackCardDirection[]['left', 'right']Which ways a card may be thrown. Left and right by default. A direction left out still follows the finger a little and then comes back, rather than refusing to move at all — a card that does not budge reads as a frozen screen.
layoutStackCardLayout'stack'How the cards behind the top one are arranged. stack steps them down and back; fan turns them alternately, like a hand of cards; flat hides them entirely, for full-bleed cards where a peeking edge is only clutter.
depthnumber2How many cards are drawn behind the top one. Two is a pile; five is a mess.
thresholdnumber0.3How far a card has to be taken for a release to send it away, as a fraction of the card. Momentum counts toward it, so a flick clears it without travelling.
disabledbooleanfalseStop the deck taking a gesture, without changing how it looks.
hapticsbooleantrueA tick when a drag first reaches the point of no return, and a knock as the card goes.
directionLabelsPartial<Record<StackCardDirection, string>>What a screen reader is offered for each direction, in place of "Swipe left". Name the decision — { left: 'Skip', right: 'Save' }.
classNamestringClasses for the whole control. Give it a height; the pile fills what is left.
pileClassNamestringClasses for the box the cards are laid out in.

StackCard.Card

PropTypeDefaultDescription
classNamestring

StackCard.Stamp

PropTypeDefaultDescription
classNamestring
directionStackCardDirectionrightWhich direction the stamp answers for. Also where on the card it goes.
labelClassNamestringExtra classes for the label, when the stamp is given a string.

StackCard.Empty

PropTypeDefaultDescription
classNamestring

StackCard.Actions

PropTypeDefaultDescription
classNamestring

StackCard.Action

PropTypeDefaultDescription
classNamestring
actionStackCardDirection | 'undo'What pressing it does: send the top card that way, or bring the last one back.
iconReactNodeThe glyph. Sized and tinted by the button — pass neither.
labelstringWhat a screen reader is offered. Falls back to "Undo", or to the plain name of the direction.
onPress() => voidRun after the deck has been told, for a sound or a log.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

The drag is one value, and everything reads it

The top card's offset is the only thing a gesture writes. The stamps fade in on how far it has carried the card toward each direction, and the cards behind climb toward the top position on the furthest of those. Nothing re-renders while a card is being dragged — the only React work in a throw is the callback at the end of it.

That derivation is also why a dismissal is seamless. By the time the top card has been carried far enough to leave, the second card is already exactly where the top card sits, so advancing the deck moves nothing.

What stays mounted

The pile keeps one card behind the depth it shows, so the next one fades in as it takes the last visible place rather than appearing out of nothing, and one card ahead of the top so undo has something to fly back in. Everything else is unmounted, which is what makes a deck of five hundred cost what a deck of five costs.

Reaching a deck without a gesture

A throw is not available to a screen reader, and neither is a card that can only be answered by throwing it. The top card publishes an accessibility action for every direction the deck accepts — name them with directionLabels, because "Swipe left" describes the movement and not the decision — and StackCard.Action renders the same decisions as ordinary buttons.

Every card but the top one is out of the reading order. A pile is one card as far as a reader is concerned, and the rest of it is shadow.

Under reduce motion

The card still goes, and it goes by fading rather than by flying. The throw is the part that moves and moving is what the setting is about; which card is on top is the information, and it is kept. The pile behind stops stepping and simply swaps.

Inside a scrolling screen

A deck that accepts only left and right declares the horizontal axis to the gesture system, so a vertical scroll starting on the card scrolls the page instead of dragging the card. A deck that accepts all four directions has nothing to give up and takes both axes — so do not put one inside a scroller.

Public exports

Values: StackCard, useStackCard

Types: StackCardProps, StackCardHandle, StackCardCardProps, StackCardStampProps, StackCardEmptyProps, StackCardActionsProps, StackCardActionProps, StackCardDirection, StackCardLayout, StackCardStampColor

On this page