/** * 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, });