High-performance Tinder-style swipe deck and swipe cards for React Native, powered by Reanimated and Gesture Handler.
- Small render window: mounts only the active card and a bounded forward stack.
- Item-keyed rendering: promoted cards keep their React Native view identity.
- Typed compound API: create one typed deck family with
createSwipeDeck<T>(). - Motion presets: tune Tinder-style drag, rotation, dismiss, next-card, and action motion.
- Reanimated-first: gesture and animation state stays on shared values/worklets.
npm install @react-native-motion-kit/swipe-deck react-native-gesture-handler react-native-reanimated react-native-workletsThen follow the Reanimated/Worklets setup for your React Native or Expo version.
Make sure react-native-worklets/plugin is the last Babel plugin.
| Package | Minimum |
|---|---|
react |
18.0.0 |
react-native |
0.75.0 |
react-native-gesture-handler |
2.24.0 |
react-native-reanimated |
4.0.0 |
react-native-worklets |
0.5.0 |
Create one typed deck family for your item type when you want Root, Card, external actions, state, and interaction hooks to share the same factory instance.
import { createSwipeDeck, SwipeDeckMotion } from '@react-native-motion-kit/swipe-deck';
const ProfileDeck = createSwipeDeck<Profile>({
motion: SwipeDeckMotion.tinder({
drag: {
mode: 'horizontal',
liftYFactor: 0.15,
},
rotation: {
mode: 'grab-position',
},
dismiss: {
threshold: ({ width }) => width * 0.3,
velocityThreshold: 800,
minDuration: 300,
maxDuration: 520,
},
}),
});
function ProfileDeckEvents() {
ProfileDeck.useDeckEventListener('swipe', ({ item, direction }) => {
console.log(item, direction);
});
ProfileDeck.useDeckEventListener('endReached', () => {
console.log('No more cards');
});
return null;
}
function ProfileDeckScreen() {
return (
<>
<ProfileDeckEvents />
<ProfileDeck.Root data={profiles} getKey={(item) => item.id} visibleCardCount={3}>
<ProfileDeck.Card>
{({ item, role, isActive }) => (
<ProfileCard profile={item} role={role} active={isActive} />
)}
</ProfileDeck.Card>
</ProfileDeck.Root>
</>
);
}The deck never renders the whole data set.
By default, it mounts up to 3 item-keyed cards from the active index forward:
- current card
- next card
- next buffered card
This keeps the active/incoming stack continuous without backfilling dismissed previous cards.
visibleCardCount is a maximum budget:
- the default is
3for smoother next-card promotion; - values below
2normalize to2when enough data exists; visibleCardCount={2}renders only the current card and the immediate next card;- the mounted count never exceeds the remaining data from the active index;
- even values are kept as-is and are not rounded up.
getKey is required because card identity is part of the rendering contract.
A key must be:
- stable for the same logical item across swipes;
- unique across different items.
That lets promoted cards keep their React Native view identity instead of reusing another item's native text subtree.
The primary API is compound/slot based:
Rootowns data, gesture state, and deck-level options.Carddefines how each item is rendered.createSwipeDeck<T>()creates a typed component family.
const ProfileDeck = createSwipeDeck<Profile>();
function ProfileDeckScreen() {
return (
<ProfileDeck.Root data={profiles} getKey={(item) => item.id}>
<ProfileDeck.Card>{({ item }) => <ProfileCard profile={item} />}</ProfileDeck.Card>
</ProfileDeck.Root>
);
}This keeps Root, Card, and future slots on the same item type without repeating generics in JSX.
Use this factory pattern when the deck needs a named instance that can be shared by hooks and external UI.
Factory hooks expose deck-wide state, actions, and Reanimated interaction values without adding a
Provider or passing a controller prop through your tree. They are scoped to the factory instance
created by createSwipeDeck<T>().
// profile-deck.ts
export const ProfileDeck = createSwipeDeck<Profile>();Use state/actions for React-rendered UI such as counters and buttons:
function ProfileDeckControls() {
const { activeIndex, count, canSwipe, canUndo, isCompleted } = ProfileDeck.useDeckState();
const { swipeLeft, swipeRight, undo } = ProfileDeck.useDeckActions();
const current = activeIndex >= 0 ? activeIndex + 1 : 0;
return (
<View>
<Text>{isCompleted ? 'Done' : `${current} / ${count}`}</Text>
<Pressable disabled={!canSwipe} onPress={swipeLeft}>
<Text>Nope</Text>
</Pressable>
<Pressable disabled={!canUndo} onPress={undo}>
<Text>Undo</Text>
</Pressable>
<Pressable disabled={!canSwipe} onPress={swipeRight}>
<Text>Like</Text>
</Pressable>
</View>
);
}Use interaction shared values for progress-driven animated UI. These values update on the UI thread and do not rerender React every gesture frame:
function SwipeReactionOverlay() {
const { signedProgress } = ProfileDeck.useDeckInteraction();
const likeStyle = useAnimatedStyle(() => {
const progress = Math.max(signedProgress.get(), 0);
return {
opacity: progress,
transform: [{ scale: 0.9 + progress * 0.18 }],
};
});
return <Animated.View pointerEvents="none" style={likeStyle} />;
}Use event hooks for committed model events such as swipe, undo, index changes, and end reached:
function ProfileDeckEvents() {
const lastSwipe = ProfileDeck.useDeckEvent('swipe', null);
const endReached = ProfileDeck.useDeckEvent('endReached', false);
ProfileDeck.useDeckEventListener('undo', ({ item }) => {
console.log('Restored', item);
});
return (
<Text>
{endReached ? 'Done' : lastSwipe ? `Last swipe: ${lastSwipe.direction}` : 'No swipe yet'}
</Text>
);
}Event hooks are commit-level, latest-value APIs:
useDeckEvent()stores only the latest event for each event name. It is not an event history.initialValueis restricted to the event payload shape,null,undefined, orfalseforendReached. Usenullfor object events such asswipe;{}is intentionally rejected so the event payload type is not widened away.- If you do pass an object initial value, the object is contextually typed from
eventName, souseDeckEvent('swipe', { ... })autocompletesitem,index, anddirection. - For a named deck without an initial value, pass the id as the second argument:
useDeckEvent('swipe', 'nearby'). If you also pass an initial value, the id remains the third argument. - Event snapshots are cleared when a Root attaches and when it detaches, so the hook returns
initialValuefor a fresh or detached deck. useDeckEventListener()is imperative. Mounted listeners stay subscribed to the factory/id store across attach/detach, but they only run when the active Root emits a new event.- Events are emitted from the commit path. During the same tick, the event payload is the source of
truth for that event; a following
useDeckState()render can settle just after the event.
Then render those controls around the same factory Root:
function ProfileDeckScreen() {
return (
<View>
<ProfileDeck.Root data={profiles} getKey={(item) => item.id} undoEnabled>
<ProfileDeck.Card>
{({ item, isActive }) => (
<View>
<ProfileCard profile={item} />
{isActive ? <SwipeReactionOverlay /> : null}
</View>
)}
</ProfileDeck.Card>
</ProfileDeck.Root>
<ProfileDeckControls />
</View>
);
}useDeckState(id?)returns React-rendered deck state such asactiveIndex,count,isCompleted,canSwipe, andcanUndo. Derive the active item from your owndata[activeIndex]when needed so deck state stays primitive and stable.useDeckActions(id?)returns stable actions such asswipeLeft(),swipeRight(), andundo(). Swipe actions returntruewhen accepted andfalsewhen the deck is unattached, disabled, animating, unmeasured, or completed.undo()returnstruewhencanUndois true, including after the deck is completed.useDeckInteraction(id?)returns Reanimated shared values for progress-driven UI. Gesture progress stays on the UI thread and does not rerender React every frame.useDeckEvent(eventName, initialValue?, id?)returns the latest committed deck event for React-rendered UI. It returnsundefinedorinitialValuebefore the first event and after the deck detaches.useDeckEventListener(eventName, listener, id?)subscribes to committed model events without forcing React state into your app code. Listener hooks clean up automatically on unmount. Successful swipe events emit inswipe -> indexChange -> endReachedorder. Undo emitsundo -> indexChange.
motion controls gesture-driven deck feel. actionMotion controls only programmatic actions
from useDeckActions(), such as a like/pass button. This keeps button-triggered motion tunable
without changing manual drag, threshold, or flick behavior.
import {
createSwipeDeck,
SwipeDeckActionMotion,
SwipeDeckMotion,
} from '@react-native-motion-kit/swipe-deck';
const ProfileDeck = createSwipeDeck<Profile>({
motion: SwipeDeckMotion.tinder(),
actionMotion: SwipeDeckActionMotion.springboard({
anticipationDistance: ({ width }) => width * 0.04,
anticipationDuration: 80,
dismissDuration: 320,
}),
});
function LikeButton() {
const actions = ProfileDeck.useDeckActions();
return <Pressable onPress={actions.swipeRight}>Like</Pressable>;
}Available recipes:
SwipeDeckActionMotion.direct(options?): dismisses immediately toward the action direction. Omitted values reuse the deck's resolved dismiss duration, easing, and offscreen multiplier.SwipeDeckActionMotion.springboard(options?): moves a little in the opposite direction first, then dismisses offscreen. During the anticipation phase, swipe progress stays neutral so opposite-side overlays do not flash;interaction.directionalso stays neutral until the dismiss phase starts. Omitted dismiss values reuse the deck's resolved dismiss duration, easing, and offscreen multiplier.
You can override one action call without changing the Root or factory default:
actions.swipeLeft(
SwipeDeckActionMotion.direct({
duration: 180,
}),
);actionMotion precedence is replacement-based, not deep-merged:
- factory
actionMotionfromcreateSwipeDeck({ actionMotion }); Root actionMotion, which replaces the factory default for that Root;- per-call recipe passed to
swipeLeft(recipe)orswipeRight(recipe).
Actions are callback-safe. If a React Native press event is passed to swipeRight or swipeLeft,
the event argument is ignored and the configured action motion is used.
Undo is opt-in. Add undoEnabled to a Root when that deck exposes undo/back-swipe UX. When
enabled, each successful swipe stores one key/index/direction metadata entry in a LIFO undo stack.
Lookups use a key-to-index map for the current data, and invalid entries are pruned when data or
keys change. When omitted, successful swipes do not store undo metadata, canUndo stays false,
and actions.undo() returns false.
When accepted, the deck temporarily renders the main stack from the restored index, animates that real current card from the side it originally left, then commits the restored index.
import { createSwipeDeck, SwipeDeckUndoMotion } from '@react-native-motion-kit/swipe-deck';
const ProfileDeck = createSwipeDeck<Profile>({
undoMotion: SwipeDeckUndoMotion.spring({
springConfig: {
damping: 36,
stiffness: 300,
mass: 3,
},
}),
});
function ProfileDeckExample() {
return (
<ProfileDeck.Root data={profiles} getKey={(item) => item.id} undoEnabled>
<ProfileDeck.Card>{({ item }) => <ProfileCard profile={item} />}</ProfileDeck.Card>
</ProfileDeck.Root>
);
}
function UndoButton() {
const state = ProfileDeck.useDeckState();
const actions = ProfileDeck.useDeckActions();
return (
<Pressable disabled={!state.canUndo} onPress={actions.undo}>
<Text>Undo</Text>
</Pressable>
);
}Undo is disabled by default so decks that do not expose undo controls pay no undo-history cost.
With undoEnabled, repeated undo restores previously swiped cards in LIFO order while their keys
remain present in data. Long-running undo-enabled decks retain one metadata entry per accepted
swipe until that entry is undone or pruned by data changes. Undo motion uses zero-duration
SwipeDeckUndoMotion.timing() by default, so the card is restored immediately unless you opt into a
custom motion.
Available recipes:
SwipeDeckUndoMotion.spring(options?): restores the card with ReanimatedwithSpring.SwipeDeckUndoMotion.timing(options?): restores the card with deterministicwithTiming.
Both recipes accept:
from: 'auto' | 'left' | 'right':autoreturns from the original swipe direction.entryDistance: number or layout callback for the offscreen start distance.
timing also accepts duration and easing; its default duration is 0. spring accepts springConfig.
Undo motion precedence is replacement-based:
- factory
undoMotionfromcreateSwipeDeck({ undoMotion }); Root undoMotion, which replaces the factory default for that Root;- per-call recipe passed to
actions.undo(recipe).
Undo is callback-safe. If a React Native press event is passed to undo, the event argument is
ignored and the configured undo motion is used. During undo restore, public interaction values
(progress, signedProgress, direction, translationX, translationY) remain neutral, so
progress-driven swipe overlays do not flash.
Use an id only when you render multiple roots from the same factory:
function MultiDeckScreen() {
const nearbyState = ProfileDeck.useDeckState('nearby');
return (
<>
<ProfileDeck.Root id="recommended" data={recommended} getKey={(item) => item.id}>
<ProfileDeck.Card>{({ item }) => <ProfileCard profile={item} />}</ProfileDeck.Card>
</ProfileDeck.Root>
<ProfileDeck.Root id="nearby" data={nearby} getKey={(item) => item.id}>
<ProfileDeck.Card>{({ item }) => <ProfileCard profile={item} />}</ProfileDeck.Card>
</ProfileDeck.Root>
<Text>{nearbyState.activeIndex + 1}</Text>
</>
);
}id is a factory-scoped deck namespace, not an item key. Two different factories can both
use the default id safely, but two mounted roots from the same factory and same id are invalid.
Keep ids stable and low-cardinality, such as screen-level names ("nearby" or "recommended").
The registry keeps one store per id for the lifetime of the factory so hooks, actions, and
interaction shared values stay stable. Do not derive ids from item ids, timestamps, values that
change per render, or one-off route values. Create factories and ids outside render paths; dynamic
factories or dynamic ids create long-lived namespaces that the registry intentionally keeps stable.
If you prefer shorter names, destructure from the same factory instance and export aliases:
const ProfileDeck = createSwipeDeck<Profile>();
export const {
Root: ProfileDeckRoot,
Card: ProfileDeckCard,
useDeckState: useProfileDeckState,
useDeckActions: useProfileDeckActions,
useDeckInteraction: useProfileDeckInteraction,
} = ProfileDeck;Create the factory once per deck family and export it from a shared module. Hooks, actions, and interactions only connect to Roots created by that same factory instance. Calling createSwipeDeck<Profile>() again creates a separate registry namespace even if the item type and id are the same, so hooks from one factory cannot control Roots from another.
If you only need card rendering, you can use the static API without creating a factory instance.
This is useful for small inline decks, but it does not expose factory hooks such as
useDeckState, useDeckActions, useDeckInteraction, useDeckEvent, or
useDeckEventListener.
Static Root does not accept id; use a factory instance when you need named deck state,
actions, interactions, or model events.
Because static Root and Card do not share a factory type, pass the item type to Card when you want typed render props.
import { SwipeDeck } from '@react-native-motion-kit/swipe-deck';
function InlineDeck() {
return (
<SwipeDeck.Root data={profiles} getKey={(item) => item.id}>
<SwipeDeck.Card<Profile>>{({ item }) => <ProfileCard profile={item} />}</SwipeDeck.Card>
</SwipeDeck.Root>
);
}Buffered next cards animate with swipe progress:
- scale moves toward
1; - opacity moves toward
1; translateYmoves toward0.
When a swipe commits:
- the dismissed card exits offscreen;
- the promoted next card keeps its item identity;
- a new future item enters the bounded window.
Tune this with motion presets such as SwipeDeckMotion.tinder(...).
drag.mode controls how the active card uses finger translation while dragging.
| mode | Active card movement |
|---|---|
free |
Follows both horizontal and vertical finger movement. |
horizontal |
Ignores vertical finger movement and moves from horizontal translation only. |
drag.liftYFactor lifts the active card upward by abs(translationX) * liftYFactor.
SwipeDeckMotion.tinder({
drag: {
mode: 'horizontal',
liftYFactor: 0,
},
rotation: {
mode: 'fixed',
origin: 'bottom-center',
},
});Use drag.mode: 'horizontal' with rotation: { mode: 'fixed', origin: 'bottom-center' } for a lower-anchor, left/right-only feel.
Motion values are resolved in this order:
- factory motion defaults from
createSwipeDeck({ motion }); Root motion, which partially overrides only the fields it specifies;- direct root props such as
swipeThresholdandvelocityThreshold.
Factory and Root motion are deep-merged. Numeric rotation tuning such as maxDegrees and inputRange is preserved unless the Root motion explicitly overrides it; changing rotation.mode does not reset those values to mode defaults.
Prefer keeping motion presets stable with a module-scope constant or useMemo.
Stable preset references avoid unnecessary gesture/worklet setup when you pass Reanimated easing functions or spring config objects.
For app-wide or deck-family defaults, define motion outside render and pass it to the factory:
const profileDeckMotion = SwipeDeckMotion.tinder({
rotation: {
mode: 'fixed',
origin: 'bottom-center',
},
dismiss: {
threshold: ({ width }) => width * 0.3,
},
});
const ProfileDeck = createSwipeDeck<Profile>({
motion: profileDeckMotion,
});When motion depends on props or state, memoize the config at the call site:
function ProfileScreen({ slowMotion }: { slowMotion: boolean }) {
const motion = useMemo(
() =>
SwipeDeckMotion.tinder({
dismiss: {
minDuration: slowMotion ? 420 : 220,
maxDuration: slowMotion ? 700 : 420,
},
}),
[slowMotion],
);
return (
<ProfileDeck.Root data={profiles} getKey={(item) => item.id} motion={motion}>
<ProfileDeck.Card>{({ item }) => <ProfileCard profile={item} />}</ProfileDeck.Card>
</ProfileDeck.Root>
);
}rotation.mode controls whether the rotation anchor is fixed or resolved from the gesture start position. The default is Tinder-like mode: 'grab-position'. Rotation settings do not change swipe recognition. When dismiss.duration is omitted, however, velocity-derived dismiss timing can change because the release target is computed from the rotated card geometry.
Use fixed rotation when every gesture should use the same anchor instead of the default grab-position behavior.
| origin | Feel |
|---|---|
center |
Rotates around the card center. |
top-center |
Rotates around the top-center edge, so the upper part feels anchored and the bottom travels through a larger arc. |
bottom-center |
Rotates around the bottom-center edge, so the lower part feels almost anchored and the top travels through a larger arc. |
SwipeDeckMotion.tinder({
rotation: {
mode: 'fixed',
origin: 'bottom-center',
direction: 'default',
},
});Use direction: 'reverse' when you want the same fixed anchor but the opposite rotation sign.
Grab-position rotation is the default Tinder-like behavior. Use it explicitly when you want to override only shared rotation values such as maxDegrees or inputRange.
SwipeDeckMotion.tinder({
rotation: {
mode: 'grab-position',
direction: 'default',
maxDegrees: 25,
},
});Upper-half grabs use a top-center anchor with the default rotation sign. Lower-half grabs use a bottom-center anchor with the reverse rotation sign. direction: 'reverse' flips that mapping. maxDegrees and inputRange are still configurable, but fixed origin is intentionally omitted in this mode.
Because the same maxDegrees feels stronger with edge-based rotation, the Tinder preset uses a smaller default rotation angle for top-center, bottom-center, and grab-position unless you provide maxDegrees explicitly.
dismiss.offscreenMultiplier controls the successful swipe release target.
- Successful swipes always dismiss offscreen.
- The default
1.5sends the card toclearDistance * 1.5. clearDistanceis resolved at release from the actual swipe direction, rotation mode, rotation direction, and gesture start position.- Values below
1are normalized to1. - If
durationis omitted, velocity-derived timing is computed from the remaining distance to this target.
Most apps can skip this option and tune only:
thresholdvelocityThresholdduration,minDuration,maxDuration- Reanimated
easing
dismiss.easing accepts the same easing value as Reanimated withTiming.
The default is Easing.out(Easing.cubic).
function CompactDeck() {
return (
<SwipeDeck.Root data={profiles} getKey={(item) => item.id} visibleCardCount={2}>
{/* minimum budget: current + immediate next */}
</SwipeDeck.Root>
);
}
function DefaultDeck() {
return (
<SwipeDeck.Root data={profiles} getKey={(item) => item.id} visibleCardCount={3}>
{/* default budget for smoother next-card promotion */}
</SwipeDeck.Root>
);
}
function DeepStackDeck() {
return (
<SwipeDeck.Root data={profiles} getKey={(item) => item.id} visibleCardCount={9}>
{/* deeper stacked UI */}
</SwipeDeck.Root>
);
}| Input | Mounted cards |
|---|---|
visibleCardCount={1} |
Up to 2 cards when enough data exists. |
visibleCardCount={2} |
Up to 2 cards when enough data exists. |
visibleCardCount={20} with 10 remaining items |
At most those 10 remaining items. |
| even values | Kept as the maximum budget; not rounded up. |
MIT
