reanimated-card-stack
v0.1.0
Published
Headless card-shuffle stack for React Native. Reanimated v3+ and Gesture Handler v2. Atomic UI-thread cycle with no React state in the critical path.
Downloads
134
Maintainers
Readme
reanimated-card-stack
A headless card-shuffle stack for React Native. The top card can be tapped or swiped vertically to cycle the deck forward, with peek cards layered behind. Inspired by Pixel "At a Glance", iOS Dynamic Island, and lockscreen notification stacks.
- Headless. You render the cards. The library owns the deck mechanics: gesture, cycle, transforms, z-order, and an optional "more available" fade.
- No React state in the cycle path. All cycle bookkeeping lives in shared values; depth-to-card mapping rotates atomically on the UI thread. No flash, no race.
- Continuous opacity. The deepest visible peek can act as a fade indicator when the data set is larger than the deck. Opacity rolls off with the effective depth so the indicator brightens smoothly as a card slides forward, instead of popping at commit.
- Generic.
CardStack<T>works with any item type; you supplykeyExtractorandrenderItem. - Tiny. One file, no dependencies beyond the standard Reanimated + Gesture Handler stack you already have.
Install
npm install reanimated-card-stack
# or
pnpm add reanimated-card-stack
# or
yarn add reanimated-card-stackPeer dependencies (these must already be installed and configured in your app):
npm install react-native-reanimated react-native-gesture-handlerWrap your app root in GestureHandlerRootView per the Gesture Handler docs.
Quick start
import { CardStack } from "reanimated-card-stack";
type Notification = { id: string; title: string; body: string };
const notifications: Notification[] = [
{ id: "1", title: "Standup at 10", body: "5 unread in #design" },
{ id: "2", title: "Lunch with K.", body: "12:30, the usual place" },
{ id: "3", title: "Battery low", body: "12% remaining" },
{ id: "4", title: "Updates ready", body: "Tap to install" },
];
export function NotificationStack() {
return (
<CardStack
data={notifications}
keyExtractor={(n) => n.id}
onTap={(n) => console.log("opened", n.id)}
onCommit={({ from, to, direction }) =>
console.log(`swiped ${direction}: ${from.id} → ${to.id}`)
}
renderItem={({ item }) => (
<View style={styles.card}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.body}>{item.body}</Text>
</View>
)}
/>
);
}Props
| Prop | Type | Default | Description |
|---------------------|-------------------------------------------------------|---------|-------------|
| data | T[] | — | Items rendered in the deck. |
| keyExtractor | (item: T, index: number) => string | — | Stable key per item. Required so React keeps slots mounted across cycles. |
| renderItem | (info: { item: T; index: number }) => ReactNode | — | Render the inner content of one card. The library does not style the card itself. |
| onTap | (item: T, index: number) => void | — | Fired on a short tap of the top card. |
| onCommit | (info: CardStackCommitInfo<T>) => void | — | Fired after a swipe commits and the deck has advanced. |
| onTopChange | (index: number) => void | — | Fired with the new top index after each commit. |
| cardHeight | number | 64 | Card height in pixels. |
| visibleCount | number | 3 | Max cards visible (top + peeks). Cards beyond are hidden until they cycle forward. |
| stepTranslateY | number | 8 | Vertical offset between depth slots. |
| stepScale | number | 0.05 | Scale shrink between depth slots. |
| indicatorOpacity | number | 0.32 | Opacity of the deepest peek when data.length > visibleCount. Set to 1 to disable. |
| commitDistance | number | 10 | Pixels the user must drag past to commit a swipe. |
| commitVelocity | number | 600 | Velocity (px/s) that auto-commits regardless of distance. |
| commitDuration | number | 320 | Duration (ms) of the commit animation. |
| pressedScale | number | 0.985 | Scale on tap-begin. Set to 1 to disable. |
| style | ViewStyle | — | Outer container style override. |
CardStackCommitInfo<T>
type CardStackCommitInfo<T> = {
from: T;
to: T;
fromIndex: number;
toIndex: number;
direction: "up" | "down";
};Building your own indicator
The library doesn't ship indicator dots — you render whatever you like with onTopChange:
const [topIndex, setTopIndex] = useState(0);
<CardStack
data={data}
keyExtractor={(d) => d.id}
renderItem={renderItem}
onTopChange={setTopIndex}
/>
<Indicator total={data.length} active={topIndex} />How the cycle works
The component renders one JSX node per item with a stable key — the React tree is stable across cycles. Each card derives its current depth from a single shared value, cycleOffset, inside its worklet:
depth = ((itemIndex - cycleOffset) mod len + len) mod lenWhen a swipe commits, the worklet callback increments cycleOffset AND resets every progress shared value in the same UI-thread frame:
topCommitT.value = withTiming(1, { ... }, (done) => {
if (done) {
cycleOffset.value += 1;
dragOffset.value = 0;
backProgress.value = 0;
topCommitT.value = 0;
}
});There is no setState on the visual-critical path — so there's no chance for React to render the new top card with the old transforms still applied. The onTopChange callback fires via useAnimatedReaction after the swap.
License
MIT
