/**
* StackCard — a pile of cards, taken one at a time by throwing the top one off.
*
* ```tsx
* decide(people[index], direction)}>
* Yes
* No
* {people.map((person) => (
*
* {person.name}
*
* ))}
*
* Nobody left
*
*
* ```
*
* 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 — and what makes it wrong for anything the reader has to compare,
* skim or come back to. A deck shows one card and hides the rest.
*
* For a run of slides the reader browses rather than disposes of, use
* [Carousel](../carousel); for one row's actions in a list, [Swipe](../swipe).
*
* ## The pile is one drag, read by everything
*
* The top card's `x` and `y` are the only values a gesture writes, and every
* other moving part is derived from them: each stamp fades in on how far the
* drag has carried the card toward its own direction, and the cards behind
* climb toward the top position on the furthest of those.
*
* That derivation is also what makes a dismissal 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 when the deck advances there is nothing
* left for it to move, and no frame in which the pile re-arranges itself.
*
* ## The deck moves on the UI thread, and React catches up
*
* Which card is on top is held twice: as React state, for what is mounted and
* for the callbacks, and as a shared value, for what is drawn. A throw is
* handled entirely on the UI thread — the card leaves on the frame the finger
* lets go, and once it is off the screen the top card, the offset and the fade
* all move in one step — and only then is the new index requested from React.
*
* Nothing on the screen waits for a render, so a busy JavaScript thread cannot
* stall a throw or leave a frame where the two halves disagree. What React
* renders afterwards is kept from showing before the UI thread agrees with it:
* a stamp is only drawn on the card the UI thread has on top.
*
* A controlled deck is still the owner's to decide. An owner that declines the
* new index gets the card back, flown in from the way it went; one that
* accepts it sees nothing move at all, because it already has.
*
* One card behind the pile's stated depth stays mounted so it can fade in as
* it takes the last visible place, and one card ahead of the top stays mounted
* so `undo` has something to fly back in. The rest are unmounted, which is
* what makes a deck of five hundred cost what a deck of five costs.
*
* ## A deck is not reachable by a gesture alone
*
* A throw is not available to a screen reader, and neither is a card that can
* only be answered by throwing it. So the top card publishes an accessibility
* action for every direction the deck accepts, and `StackCard.Action` renders
* the same decisions as ordinary buttons — which sighted people reach for too,
* on the card they are not sure about.
*/
import {
Children,
cloneElement,
createContext,
forwardRef,
isValidElement,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useReducer,
useRef,
type ReactElement,
type ReactNode,
} from 'react';
import {
View,
type AccessibilityActionEvent,
type LayoutChangeEvent,
type ViewProps,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
cancelAnimation,
Easing,
interpolate,
runOnJS,
runOnUI,
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useReducedMotion,
useSharedValue,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { useCSSVariable } from 'uniwind';
import { tv, type VariantProps } from 'tailwind-variants';
import { IconColorProvider } from '../../icons';
import { AnimatedPressable } from '../../primitives/animated-pressable';
import { useControllableState } from '../../primitives/controllable-state';
import { Text } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { impactKnock, selectionTick } from '../../utils/haptics';
import {
depthOpacity,
directionProgress,
effectiveDepth,
exitDuration,
exitTarget,
lever,
releaseProgress,
releasedDirection,
resist,
tiltAngle,
type StackCardDirection,
} from './stack-card-geometry';
export type { StackCardDirection } from './stack-card-geometry';
/** Puts a card back when the release did not send it anywhere. */
const RETURN_SPRING = { damping: 20, stiffness: 220, mass: 0.7 } as const;
/**
* Brings an undone card back in. Heavier than the one that recovers a drag: a
* card arriving from off the screen has further to travel and nothing under
* the finger to explain a fast stop.
*/
const ARRIVE_SPRING = { damping: 22, stiffness: 160, mass: 0.9 } as const;
/*
* How a card leaves: a gentle ease-out that starts twice as fast as its
* average, timed so that start is the finger's speed.
*
* Not a spring. A spring pulls in proportion to how far it has to go, and a
* card's destination is off the screen, so it accelerated away from the finger
* and was gone in about 100ms — a throw that read as the card being snatched.
*
* Gentle, because only the first part of the curve is seen. The card is aimed
* well past the edge so it clears it tilted, and it is out of sight about two
* thirds of the way there; a steeper curve spends most of its time on the
* part nobody sees. The durations are set for what is visible — roughly a
* quarter of a second of card crossing the screen.
*/
const EXIT_EASE = Easing.bezier(0.33, 0.66, 0.4, 1);
/** `EXIT_EASE`'s speed at its start, as a multiple of its average. */
const EXIT_SLOPE = 2;
/** The quickest a card may leave, however hard it was thrown, in milliseconds. */
const EXIT_SHORTEST = 420;
/** The longest a card takes to leave, and what a button's card takes. */
const EXIT_LONGEST = 540;
/** How long a fade stands in for a throw under reduce motion. */
const FADE_DURATION = 160;
/** How far a finger gets on an axis the deck does not accept, at most. */
const LOCKED_AXIS_GIVE = 0.18;
/*
* The default, out here rather than in the destructure. A literal in the
* parameter list is a new array on every render, and this one is a dependency
* of the pan gesture — so it would rebuild the gesture, and re-attach the
* handler, while a finger was still down on it.
*/
const SIDEWAYS: StackCardDirection[] = ['left', 'right'];
/** Degrees the top card turns through at most. */
const MAX_TILT = 14;
/** How much lower each card behind the top one sits, in points. */
const PEEK = 14;
/** How much smaller each card behind the top one is drawn. */
const SHRINK = 0.055;
/** Degrees a fanned card is turned per place back in the pile. */
const FAN_TILT = 4;
/** Sideways splay of a fanned card per place back, in points. */
const FAN_SPREAD = 10;
/** Card sizes to fall back on before the pile has been measured. */
const UNMEASURED_WIDTH = 320;
const UNMEASURED_HEIGHT = 420;
/* -------------------------------------------------------------------------- */
/* Context */
/* -------------------------------------------------------------------------- */
interface StackCardContextValue {
/** The top card's offset. Written only by the pan and the exit animation. */
x: SharedValue;
y: SharedValue;
/** The top card's opacity, which only reduce motion ever moves. */
fade: SharedValue;
/** Which way the top card pivots, from where it was taken hold of. */
pivot: SharedValue;
/** The furthest any accepted direction has been carried, 0 to 1. */
release: SharedValue;
/** Which card is on top, as the UI thread draws it. */
active: SharedValue;
width: SharedValue;
height: SharedValue;
threshold: number;
/** Where the deck is in React, which is what a caller reads. */
index: number;
count: number;
disabled: boolean;
canUndo: boolean;
send: (direction: StackCardDirection) => void;
undo: () => void;
reset: () => void;
}
const StackCardContext = createContext(null);
/*
* Which card a stamp is drawn on.
*
* Stamps are handed to whichever card React has on top, and straight after a
* throw React is a render behind the UI thread — so for that render they sit
* on the next card while the offset still says the last one went all the way.
* A stamp reads it to show only on the card the UI thread is drawing on top.
*/
const StackCardSlotIndex = createContext(null);
function useStackCardContext(component: string) {
const context = useContext(StackCardContext);
if (!context) throw new Error(`${component} must be used within a `);
return context;
}
/**
* The deck's state, for a control that lives outside the pile — a counter, a
* progress bar, a button in a toolbar.
*
* `release` is a shared value running 0 to 1 as the top card is carried toward
* leaving, so something beside the deck can move with the drag rather than
* starting a second animation next to it.
*/
export function useStackCard() {
const { index, count, canUndo, release, send, undo, reset } =
useStackCardContext('useStackCard');
return {
/** How many cards have been answered, which is also the top card's index. */
index,
/** How many cards the deck was given. */
count,
/** How many are left, the top one included. */
remaining: Math.max(0, count - index),
/** Whether there is a card to bring back. */
canUndo,
release,
send,
undo,
reset,
};
}
/* -------------------------------------------------------------------------- */
/* Root */
/* -------------------------------------------------------------------------- */
/** How the cards behind the top one are arranged. */
export type StackCardLayout = 'stack' | 'fan' | 'flat';
export interface StackCardHandle {
/** Send the top card away as though it had been thrown that way. */
swipe: (direction: StackCardDirection) => void;
/** Bring the last card back, and with it the decision that removed it. */
undo: () => void;
/** Put every card back. */
reset: () => void;
}
const stackCardVariants = tv({
slots: {
root: 'w-full',
/*
* The cards are laid over each other, so the pile has no height of its
* own and takes what is left after anything else in the root. That is what
* lets a caller state one height for the whole control and get a row of
* buttons under a pile that fills the rest.
*/
pile: 'relative w-full flex-1',
card: 'absolute inset-0',
},
});
export interface StackCardProps extends Omit {
/**
* A `StackCard.Card` for each card, plus any of `StackCard.Stamp`,
* `StackCard.Empty` and `StackCard.Actions`, in any order. Anything else is
* laid out under the pile.
*/
children?: ReactNode;
/**
* Which 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.
*/
index?: number;
/** Which card an uncontrolled deck starts on. */
defaultIndex?: number;
/** Fires whenever the deck asks to move, with the index it is asking for. */
onIndexChange?: (index: number) => void;
/**
* Fires 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.
*/
onSwipe?: (direction: StackCardDirection, index: number) => void;
/** Fires once when the last card leaves. */
onEmpty?: () => void;
/**
* 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.
*/
directions?: readonly StackCardDirection[];
/**
* 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.
*/
layout?: StackCardLayout;
/** How many cards are drawn behind the top one. Two is a pile; five is a mess. */
depth?: number;
/**
* How 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.
*/
threshold?: number;
/** Stop the deck taking a gesture, without changing how it looks. */
disabled?: boolean;
/** A tick when a drag first reaches the point of no return, and a knock as the card goes. */
haptics?: boolean;
/**
* What a screen reader is offered for each direction, in place of "Swipe
* left". Name the decision — `{ left: 'Skip', right: 'Save' }`.
*/
directionLabels?: Partial>;
/** Classes for the whole control. Give it a height; the pile fills what is left. */
className?: string;
/** Classes for the box the cards are laid out in. */
pileClassName?: string;
}
const StackCardRoot = forwardRef(
(
{
children,
index: indexProp,
defaultIndex = 0,
onIndexChange,
onSwipe,
onEmpty,
directions = SIDEWAYS,
layout = 'stack',
depth = 2,
threshold = 0.3,
disabled = false,
haptics = true,
directionLabels,
className,
pileClassName,
...props
},
ref
) => {
const cards: ReactElement[] = [];
const stamps: ReactElement[] = [];
let empty: ReactNode = null;
const below: ReactNode[] = [];
for (const child of Children.toArray(children)) {
if (!isValidElement(child)) continue;
if (child.type === StackCardCard) cards.push(child);
else if (child.type === StackCardStamp) stamps.push(child);
else if (child.type === StackCardEmpty) empty = child;
else below.push(child);
}
const count = cards.length;
const { value: index, setValue: setIndex } = useControllableState({
value: indexProp,
defaultValue: defaultIndex,
onChange: onIndexChange,
});
const x = useSharedValue(0);
const y = useSharedValue(0);
const fade = useSharedValue(1);
const pivot = useSharedValue(1);
const active = useSharedValue(index);
const width = useSharedValue(0);
const height = useSharedValue(0);
const reduceMotion = useReducedMotion();
/*
* The accepted directions as one stable array, keyed on what is in it
* rather than on the identity of the prop.
*
* A caller writing `directions={['left', 'right']}` hands over a new array
* on every render, and the whole drag path hangs off this value: the
* gesture that reads it, the axis constraint built from it, and the
* accessibility actions published from it. Keyed on contents, all three
* are rebuilt when the directions genuinely change and at no other time.
*
* A plain array rather than a shared value, because a worklet may capture
* one directly — which is how every other gesture in the library reaches
* its configuration, and it costs no per-frame allocation across the
* bridge. The gesture is rebuilt when the contents change, which is also
* exactly when the axis constraint below has to be rebuilt anyway.
*/
const directionKey = directions.join(' ');
// eslint-disable-next-line react-hooks/exhaustive-deps
const allowed = useMemo(() => [...directions], [directionKey]);
const release = useDerivedValue(
() => releaseProgress(allowed, x.value, y.value, width.value, height.value, threshold),
[allowed, threshold]
);
/*
* Which way each answered card went, so `undo` can put one back out where
* it came from before bringing it in. A ref rather than state: nothing is
* rendered from it, and a decision recorded at the end of an animation
* must not schedule a render of its own.
*/
const history = useRef([]);
/** Set when a card should arrive rather than simply appear. */
const arriving = useRef(null);
const reportedEmpty = useRef(false);
/**
* The card the UI thread is drawing on top, as far as this side knows. It
* runs ahead of `index` for the length of one render after every throw.
*/
const drawn = useRef(index);
/** The throw React has been asked to accept, until the next render answers. */
const requested = useRef<{ from: number; direction: StackCardDirection } | null>(null);
/*
* Guarantees that render. An owner declining a request need not render at
* all, and without one the deck would never find out it had been declined.
*/
const [, answer] = useReducer((renders: number) => renders + 1, 0);
/** A card is on its way out. One at a time, whoever asked. */
const leaving = useSharedValue(false);
/** Where the drag picked the card up, so it carries on from there. */
const originX = useSharedValue(0);
const originY = useSharedValue(0);
useEffect(
() => () => {
cancelAnimation(x);
cancelAnimation(y);
cancelAnimation(fade);
},
[fade, x, y]
);
/*
* Puts the deck at `next` in a single step on the UI thread: which card is
* on top, where the offset is, and whether it is showing.
*
* All of it in one worklet, because these are three shared values that
* the pile reads together. Written one at a time from React, each lands
* whenever the UI thread picks it up — and a frame that has the offset
* back at the middle while the old card is still the top one draws that
* card back in the middle of the screen, for as long as the gap lasts.
*/
const settle = useCallback(
(next: number, fromX: number, fromY: number, arrive: boolean) => {
'worklet';
cancelAnimation(x);
cancelAnimation(y);
cancelAnimation(fade);
leaving.value = false;
active.value = next;
fade.value = 1;
if (!arrive) {
x.value = 0;
y.value = 0;
return;
}
x.value = fromX;
y.value = fromY;
x.value = withSpring(0, ARRIVE_SPRING);
y.value = withSpring(0, ARRIVE_SPRING);
},
[active, fade, leaving, x, y]
);
/*
* React catching up with the deck, after every render.
*
* A thrown card moves the deck on the UI thread before React hears about
* it, so by the time this runs an accepted throw has nothing left to do.
* What it handles is everything that did not start as a throw — `undo`,
* `reset`, an owner moving `index` — and a throw that was declined, which
* is already off the screen and has to be brought back the way it went.
*/
useEffect(() => {
const asked = requested.current;
requested.current = null;
if (asked && index === asked.from) {
history.current.pop();
arriving.current = asked.direction;
}
if (drawn.current === index) return;
drawn.current = index;
const entrance = arriving.current;
arriving.current = null;
if (!entrance || reduceMotion) {
runOnUI(settle)(index, 0, 0, false);
return;
}
const from = exitTarget(
entrance,
width.value || UNMEASURED_WIDTH,
height.value || UNMEASURED_HEIGHT,
0,
0
);
runOnUI(settle)(index, from.x, from.y, true);
});
useEffect(() => {
if (index >= count && count > 0 && !reportedEmpty.current) {
reportedEmpty.current = true;
onEmpty?.();
return;
}
if (index < count) reportedEmpty.current = false;
}, [count, index, onEmpty]);
/*
* Runs once the outgoing card is off the screen, and the deck has already
* moved on. `onSwipe` goes first, so an owner deciding whether to accept
* the index has already heard which way the card went.
*/
const requestNext = useCallback(
(direction: StackCardDirection, from: number) => {
drawn.current = from + 1;
requested.current = { from, direction };
history.current.push(direction);
onSwipe?.(direction, from);
setIndex(from + 1);
answer();
if (haptics) impactKnock();
},
[haptics, onSwipe, setIndex]
);
/*
* `requestNext` closes over the caller's `onSwipe`, which an owner passing
* an inline arrow makes a new function on every render. The throw below is
* part of the gesture, so reaching it through a ref is what lets the
* gesture be built once and keep the touch it already has.
*/
const latestRequestNext = useRef(requestNext);
latestRequestNext.current = requestNext;
const reportGone = useCallback((direction: StackCardDirection, from: number) => {
latestRequestNext.current(direction, from);
}, []);
/*
* Sends the top card off, on the UI thread, whoever asked.
*
* A release calls this from inside the gesture, so the card keeps moving
* on the frame the finger lets go rather than waiting for the JavaScript
* thread to hear about it — which, in a development build or on a busy
* screen, was long enough to see the card stop and set off again, and
* long enough for a second finger to catch the card that had just left.
*
* A thrown card leaves at the speed the finger let go of it, and a card
* sent by a button at the pace of an unhurried throw. Both finish with the
* card clear of the screen, and the axis it leaves along decides when.
*/
const launch = useCallback(
(direction: StackCardDirection, velocityX: number, velocityY: number) => {
'worklet';
const from = active.value;
if (leaving.value || from >= count) return;
leaving.value = true;
const gone = (finished?: boolean) => {
'worklet';
// Caught mid-flight: the finger has it now, and the deck has not moved.
if (!finished) {
leaving.value = false;
return;
}
settle(from + 1, 0, 0, false);
runOnJS(reportGone)(direction, from);
};
if (reduceMotion) {
// The throw is the part that moves, and moving is the part the
// setting is about. The card still goes; it goes by fading.
fade.value = withTiming(0, { duration: FADE_DURATION }, gone);
return;
}
const target = exitTarget(
direction,
width.value || UNMEASURED_WIDTH,
height.value || UNMEASURED_HEIGHT,
x.value,
y.value
);
const sideways = direction === 'left' || direction === 'right';
const distance = sideways ? target.x - x.value : target.y - y.value;
const along = sideways ? velocityX : velocityY;
// Only speed toward the way out counts; a card flicked back the other
// way and sent on by distance is leaving from a standstill.
const speed = along * distance > 0 ? Math.abs(along) : 0;
const timing = {
duration: exitDuration(distance, speed, EXIT_SLOPE, EXIT_SHORTEST, EXIT_LONGEST),
easing: EXIT_EASE,
};
x.value = withTiming(target.x, timing, sideways ? gone : undefined);
y.value = withTiming(target.y, timing, sideways ? undefined : gone);
},
[active, count, fade, height, leaving, reduceMotion, reportGone, settle, width, x, y]
);
const send = useCallback(
(direction: StackCardDirection) => {
runOnUI(launch)(direction, 0, 0);
},
[launch]
);
const undo = useCallback(() => {
// `drawn`, not `index`: straight after a throw React is a render behind.
const current = drawn.current;
if (current <= 0) return;
arriving.current = history.current.pop() ?? 'left';
setIndex(current - 1);
}, [setIndex]);
const reset = useCallback(() => {
history.current = [];
arriving.current = null;
requested.current = null;
drawn.current = 0;
runOnUI(settle)(0, 0, 0, false);
setIndex(0);
answer();
}, [setIndex, settle]);
useImperativeHandle(ref, () => ({ swipe: send, undo, reset }), [send, undo, reset]);
/** Stable for the life of the deck, for the accessibility actions below. */
const latestSend = useRef(send);
latestSend.current = send;
const dispatch = useCallback((direction: StackCardDirection) => {
latestSend.current(direction);
}, []);
/*
* One tick, when the drag first reaches the point where letting go would
* send the card. Latched on the reaction's own previous value, so dragging
* back and forth across the line does not rattle — and fired at the
* crossing rather than at the release, because the crossing is the moment
* worth knowing about while there is still a choice.
*/
useAnimatedReaction(
() => release.value >= 1,
(past, wasPast) => {
if (wasPast === null || past === wasPast || !past) return;
runOnJS(tick)(haptics);
},
[haptics]
);
/**
* Whether a finger can take a card at all. A boolean, so it changes at
* most twice in a deck's life and the gesture keeps its identity across
* every advance.
*/
const enabled = !disabled && index < count && allowed.length > 0;
const gesture = useMemo(() => {
/*
* Which axes the deck answers to. An activation constraint is part of
* how a gesture is constructed rather than something a handler can
* decide, so both are read here, from the array this memo is keyed on.
*/
const sideways = allowed.indexOf('left') >= 0 || allowed.indexOf('right') >= 0;
const upright = allowed.indexOf('up') >= 0 || allowed.indexOf('down') >= 0;
/*
* Built as one chain from `Gesture.Pan()`, and each handler says
* `'worklet'` for itself. Both matter: the callbacks are only compiled
* for the UI thread when they can be recognised as a gesture's, and a
* handler that quietly stays on the JS thread is not an error anywhere
* — it is a drag that reports a frame late and writes shared values from
* the wrong side.
*/
const pan = Gesture.Pan()
.enabled(enabled)
.onBegin((event) => {
'worklet';
pivot.value = lever(event.y, height.value);
})
.onStart((event) => {
'worklet';
/*
* Taken hold of here, when the pan activates, and not on touch-down.
* A finger that lands and lifts without moving is not a drag, and
* stopping the card on contact would leave a card that was on its
* way back frozen wherever the tap caught it.
*
* The origin is where the card is now, less the distance the finger
* travelled to activate the pan. Without it the card jumps by that
* distance on the first frame of every drag, and a card caught in
* flight jumps back under the finger's starting point.
*/
cancelAnimation(x);
cancelAnimation(y);
// Under reduce motion a leaving card fades rather than flies, and a
// card caught while it fades has to stop going as well as moving.
cancelAnimation(fade);
fade.value = 1;
originX.value = x.value - event.translationX;
originY.value = y.value - event.translationY;
})
.onUpdate((event) => {
'worklet';
const nextX = originX.value + event.translationX;
const nextY = originY.value + event.translationY;
x.value = sideways ? nextX : resist(nextX, width.value, LOCKED_AXIS_GIVE);
y.value = upright ? nextY : resist(nextY, height.value, LOCKED_AXIS_GIVE);
})
.onEnd((event) => {
'worklet';
const direction = releasedDirection(
allowed,
x.value,
y.value,
event.velocityX,
event.velocityY,
width.value,
height.value,
threshold
);
if (direction) {
launch(direction, event.velocityX, event.velocityY);
return;
}
// The velocity goes into the spring, so there is no seam between
// the finger letting go and the card carrying on.
x.value = withSpring(0, { ...RETURN_SPRING, velocity: event.velocityX });
y.value = withSpring(0, { ...RETURN_SPRING, velocity: event.velocityY });
});
/*
* The axis constraint goes on last, after the chain the plugin had to
* see. A pan with no declared axis inside a scrolling screen wins every
* scroll that starts on the card, and the screen reads as broken in a
* way that looks like a scrolling bug rather than a gesture one. So a
* deck that answers to one axis says which, and a deck that answers to
* both declares nothing — it has no scroll to give way to that it would
* not also have to take a card from. Declaring a 1px threshold is not
* the same as declaring nothing: it activates on almost any movement and
* still takes the scroll.
*/
if (sideways && !upright) return pan.activeOffsetX([-10, 10]);
if (upright && !sideways) return pan.activeOffsetY([-10, 10]);
return pan;
}, [
allowed,
enabled,
fade,
height,
launch,
originX,
originY,
pivot,
threshold,
width,
x,
y,
]);
const context = useMemo(
() => ({
x,
y,
fade,
pivot,
release,
active,
width,
height,
threshold,
index,
count,
disabled,
canUndo: index > 0,
send,
undo,
reset,
}),
[
active,
count,
disabled,
fade,
height,
index,
pivot,
release,
reset,
send,
threshold,
undo,
width,
x,
y,
]
);
const slots = stackCardVariants();
/*
* The window of cards that stay mounted: one behind the last visible
* place, so it fades in rather than appearing, and one ahead of the top,
* so `undo` has a card to fly back in.
*/
const first = Math.max(0, index - 1);
const last = Math.min(count - 1, index + depth + 1);
/*
* Keyed on the labels themselves, for the same reason `allowed` is: a
* caller writing the labels inline hands over a new object every render,
* and this array is a prop of the card the drag is moving.
*/
const labelKey = directions.map((direction) => directionLabels?.[direction] ?? '').join(' ');
const accessibilityActions = useMemo(
() =>
allowed.map((direction) => ({
name: direction,
label: directionLabels?.[direction] ?? DEFAULT_DIRECTION_LABELS[direction],
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[allowed, labelKey]
);
/** Stable for the life of the deck: `dispatch` closes over nothing. */
const onAccessibilityAction = useCallback(
(event: AccessibilityActionEvent) =>
dispatch(event.nativeEvent.actionName as StackCardDirection),
[dispatch]
);
return (
{/*
* One detector, on the pile, for the life of the deck.
*
* Not on the top card, which is the arrangement that reads as
* natural and is the one thing in this file that nothing else in the
* library does. A detector mounted per card moves as the deck
* advances, and a gesture object carries a single mutable handler
* tag that the detector registers on attach and reads back on
* cleanup — so the detector being unmounted can drop the tag the
* newly mounted one has just claimed, leaving a live detector
* pointing at a destroyed native handler. The next touch reaches
* freed memory, which is a crash with no JavaScript frames in it.
*
* On the pile it never moves, and the gesture has no reason to: it
* writes one offset that only the top card's style reads.
*/}
{
width.value = event.nativeEvent.layout.width;
height.value = event.nativeEvent.layout.height;
}}
className={slots.pile({ className: pileClassName })}
>
{index >= count ? empty : null}
{cards.map((card, cardIndex) => {
if (cardIndex < first || cardIndex > last) return null;
const top = cardIndex === index;
const live = top && !disabled;
return (
{card}
{top ? stamps : null}
);
})}
{below}
);
}
);
/** What a screen reader is offered when the caller names nothing better. */
const DEFAULT_DIRECTION_LABELS: Record = {
left: 'Swipe left',
right: 'Swipe right',
up: 'Swipe up',
down: 'Swipe down',
};
/**
* Scheduled from the threshold reaction, so the question of whether haptics
* are wanted at all is answered off the UI thread.
*/
function tick(enabled: boolean) {
if (enabled) selectionTick();
}
/* -------------------------------------------------------------------------- */
/* Slot */
/* -------------------------------------------------------------------------- */
interface StackCardSlotProps {
cardIndex: number;
depth: number;
layout: StackCardLayout;
/** Whether this is the card on top, which is the only one a reader is shown. */
top: boolean;
/** Whether it also takes touches — false while the deck is disabled. */
live: boolean;
children: ReactNode;
accessibilityActions?: { name: string; label: string }[];
onAccessibilityAction?: (event: AccessibilityActionEvent) => void;
}
/**
* One place in the pile, and the rule that puts a card there.
*
* A slot styles itself from its own distance to the top rather than being told
* where to sit, so the deck advancing is one shared value changing and no
* re-render at all — and a card mid-flight is styled by the same rule as the
* pile behind it rather than by a second one that has to agree with it.
*/
function StackCardSlot({
cardIndex,
depth,
layout,
top,
live,
children,
accessibilityActions,
onAccessibilityAction,
}: StackCardSlotProps) {
const { x, y, fade, pivot, release, active, width } = useStackCardContext('StackCard.Card');
const { card } = stackCardVariants();
/** Fixed per card, so a fan does not re-deal itself as the deck advances. */
const side = cardIndex % 2 === 0 ? 1 : -1;
/*
* Every branch returns the same four transforms in the same order, and says
* what it does not use with an identity value rather than by leaving the
* entry out. A card crosses between these branches as the deck advances, and
* a transform list that changes length or order between two commits is read
* natively as a different list — which is a crash, not a jump.
*/
const style = useAnimatedStyle(() => {
const distance = cardIndex - active.value;
/*
* Answered, and still mounted only so `undo` has something to bring back.
* Drawn nowhere until it is asked for.
*/
if (distance < 0) {
return {
opacity: 0,
zIndex: 0,
transform: [{ translateX: 0 }, { translateY: 0 }, { rotate: '0deg' }, { scale: 1 }],
};
}
if (distance === 0) {
return {
opacity: fade.value,
zIndex: 200,
transform: [
{ translateX: x.value },
{ translateY: y.value },
{ rotate: `${tiltAngle(x.value, width.value, MAX_TILT, pivot.value)}deg` },
{ scale: 1 },
],
};
}
const behind = effectiveDepth(distance, release.value);
const opacity = depthOpacity(behind, depth);
const zIndex = Math.round(100 - behind * 10);
if (layout === 'flat') {
// Nothing is drawn behind the top card, so the next one waits exactly
// where the top card is and is simply uncovered as that one leaves.
return {
opacity: behind < 1 ? opacity : 0,
zIndex,
transform: [{ translateX: 0 }, { translateY: 0 }, { rotate: '0deg' }, { scale: 1 }],
};
}
if (layout === 'fan') {
return {
opacity,
zIndex,
transform: [
{ translateX: side * behind * FAN_SPREAD },
{ translateY: behind * PEEK * 0.35 },
{ rotate: `${side * behind * FAN_TILT}deg` },
{ scale: 1 - behind * SHRINK * 0.6 },
],
};
}
return {
opacity,
zIndex,
transform: [
{ translateX: 0 },
{ translateY: behind * PEEK },
{ rotate: '0deg' },
{ scale: 1 - behind * SHRINK },
],
};
});
return (
{children}
);
}
/* -------------------------------------------------------------------------- */
/* Card */
/* -------------------------------------------------------------------------- */
export interface StackCardCardProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* One card. Filled, bordered and rounded out of the box, so a deck of plain
* content already reads as a deck, and restyled from `className` like anything
* else. It fills the pile on both axes: the pile's box is the card's size.
*/
const StackCardCard = forwardRef(({ className, ...props }, ref) => (
));
/* -------------------------------------------------------------------------- */
/* Stamp */
/* -------------------------------------------------------------------------- */
/**
* A stamp is a filled block of colour with a word on it, turned a few degrees
* so it reads as pressed onto the card rather than laid out on it.
*
* The fill is the status colour at full strength rather than a tint of it: a
* stamp exists only for the moment it appears, and a six-per-cent wash of the
* card's own surface is a smudge rather than an answer. The word is carried in
* white, which is what the status colours are chosen to take — a status's
* `-foreground` token is its darker text form, meant for a neutral surface,
* and over the fill it is the same hue twice.
*
* It sits in the corner the card is being pulled away from, which is also the
* corner the thumb is not over.
*/
const stampVariants = tv({
slots: {
root: 'absolute inset-x-6 z-10',
pill: 'rounded-xl px-4 py-2',
label: 'text-lg font-bold uppercase tracking-widest',
},
variants: {
color: {
default: { pill: 'bg-muted-foreground', label: 'text-background' },
primary: { pill: 'bg-primary', label: 'text-primary-foreground' },
success: { pill: 'bg-success', label: 'text-success-solid-foreground' },
warning: { pill: 'bg-warning', label: 'text-warning-solid-foreground' },
info: { pill: 'bg-info', label: 'text-info-solid-foreground' },
destructive: {
pill: 'bg-destructive',
label: 'text-destructive-solid-foreground',
},
},
direction: {
left: { root: 'top-6 items-end', pill: 'rotate-12' },
right: { root: 'top-6 items-start', pill: '-rotate-12' },
up: { root: 'bottom-6 items-center' },
down: { root: 'top-6 items-center' },
},
},
defaultVariants: {
color: 'default',
direction: 'right',
},
});
export type StackCardStampColor =
| 'default'
| 'primary'
| 'success'
| 'warning'
| 'info'
| 'destructive';
export interface StackCardStampProps
extends Omit,
VariantProps {
className?: string;
/** The word, or anything else to draw on the stamp. */
children?: ReactNode;
/** Which direction the stamp answers for. Also where on the card it goes. */
direction?: StackCardDirection;
/** Extra classes for the label, when the stamp is given a string. */
labelClassName?: string;
}
/**
* The answer a throw is about to give, faded in as the card is carried toward
* giving it.
*
* It reaches full strength exactly where letting go would commit, so a solid
* stamp and the haptic tick are the same statement made twice — which is the
* point, since the haptic is off for a lot of people and silent on most
* Android hardware.
*/
const StackCardStamp = forwardRef(
(
{
className,
labelClassName,
color = 'default',
direction = 'right',
children,
style: styleProp,
...props
},
ref
) => {
const { x, y, width, height, threshold, active } = useStackCardContext('StackCard.Stamp');
const slotIndex = useContext(StackCardSlotIndex);
const slots = stampVariants({ color, direction });
const style = useAnimatedStyle(() => {
// Not on a card the UI thread has not put on top yet. Outside a slot
// there is no card to disagree with, so it follows the drag as before.
const onTop = slotIndex === null || slotIndex === active.value;
const progress = onTop
? directionProgress(direction, x.value, y.value, width.value, height.value, threshold)
: 0;
return {
opacity: progress,
// Landing rather than appearing: it is stamped on as the card commits.
transform: [{ scale: interpolate(progress, [0, 1], [0.8, 1]) }],
};
});
return (
{typeof children === 'string' || typeof children === 'number' ? (
{children}
) : (
children
)}
);
}
);
/* -------------------------------------------------------------------------- */
/* Empty */
/* -------------------------------------------------------------------------- */
export interface StackCardEmptyProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* What is under the deck once the last card has gone.
*
* Mounted only when the deck is exhausted, so a card flying off never crosses
* it. A deck with none of these leaves the empty box the cards were in, which
* is the right answer when something else on the screen already says the queue
* is finished.
*/
const StackCardEmpty = forwardRef(({ className, ...props }, ref) => (
));
/* -------------------------------------------------------------------------- */
/* Actions */
/* -------------------------------------------------------------------------- */
export interface StackCardActionsProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The row of buttons under the pile. Laid out after it rather than over it, so
* the buttons are not competing with the card for the same touches.
*/
const StackCardActions = forwardRef(
({ className, ...props }, ref) => (
)
);
const actionVariants = tv({
slots: {
root: 'items-center justify-center rounded-full border',
},
variants: {
color: {
default: { root: 'border-border bg-card' },
primary: { root: 'border-primary bg-primary' },
success: { root: 'border-success bg-success' },
warning: { root: 'border-warning bg-warning' },
info: { root: 'border-info bg-info' },
destructive: { root: 'border-destructive bg-destructive' },
},
size: {
sm: { root: 'h-10 w-10' },
md: { root: 'h-14 w-14' },
lg: { root: 'h-16 w-16' },
},
},
defaultVariants: {
color: 'default',
size: 'md',
},
});
/** How big a glyph is drawn on each button size. */
const ACTION_ICON_SIZE = { sm: 18, md: 24, lg: 28 } as const;
export interface StackCardActionProps
extends Omit,
VariantProps {
className?: string;
/** What pressing it does: send the top card that way, or bring the last one back. */
action: StackCardDirection | 'undo';
/** The glyph. Sized and tinted by the button — pass neither. */
icon?: ReactNode;
/**
* What a screen reader is offered. Falls back to "Undo", or to the plain
* name of the direction.
*/
label?: string;
/** Run after the deck has been told, for a sound or a log. */
onPress?: () => void;
}
/**
* One decision as an ordinary button.
*
* This is the accessible path through a deck, and it is also the path a lot of
* sighted people take on the card they are unsure about: a throw looks
* irreversible in a way a tap does not, so the pair is not redundant.
*
* `undo` disables itself on the first card, where there is nothing to bring
* back, and every other action disables itself once the deck is empty — a
* button that still looks pressable over an exhausted deck is the commonest
* way one of these ends up feeling broken.
*/
const StackCardAction = forwardRef(
({ className, action, icon, label, color = 'default', size = 'md', onPress, ...props }, ref) => {
const context = useStackCardContext('StackCard.Action');
const slots = actionVariants({ color, size });
const tint = useActionTint(color as StackCardStampColor);
const isUndo = action === 'undo';
const disabled =
context.disabled || (isUndo ? !context.canUndo : context.index >= context.count);
return (
{
if (isUndo) context.undo();
else context.send(action);
onPress?.();
}}
{...props}
className={slots.root({ className: cn(disabled && 'opacity-40', className) })}
>
{sizeIcon(icon, size ?? 'md')}
);
}
);
/**
* The glyph at button size, unless the caller asked for one. Sized here rather
* than at every call site, because a row of these reads as a set only while
* the icons in it match.
*/
function sizeIcon(icon: ReactNode, size: 'sm' | 'md' | 'lg'): ReactNode {
if (!isValidElement<{ size?: number }>(icon)) return icon;
if (icon.props.size !== undefined) return icon;
return cloneElement(icon, { size: ACTION_ICON_SIZE[size] });
}
/**
* The colour a glyph is drawn in on a button — the token that reads against
* its fill. Resolved from the theme wherever the theme has an answer, since a
* hex stops being right the moment the theme inverts; white is the exception,
* because a status fill is the same saturated colour in every theme and white
* is what it carries.
*
* Both tokens are read on every render because a hook cannot be called for one
* branch only. They are variable lookups, not work.
*/
function useActionTint(color: StackCardStampColor): string | undefined {
const foreground = useCSSVariable('--color-foreground');
const primary = useCSSVariable('--color-primary-foreground');
if (color === 'default') return typeof foreground === 'string' ? foreground : undefined;
if (color === 'primary') return typeof primary === 'string' ? primary : undefined;
return '#ffffff';
}
StackCardRoot.displayName = 'StackCard';
StackCardCard.displayName = 'StackCard.Card';
StackCardStamp.displayName = 'StackCard.Stamp';
StackCardEmpty.displayName = 'StackCard.Empty';
StackCardActions.displayName = 'StackCard.Actions';
StackCardAction.displayName = 'StackCard.Action';
export const StackCard = Object.assign(StackCardRoot, {
Card: StackCardCard,
Stamp: StackCardStamp,
Empty: StackCardEmpty,
Actions: StackCardActions,
Action: StackCardAction,
});