Swipe Row
A list row with actions behind it; swipe far enough and the edge action takes over the row and runs.
import { useState, type ReactNode } from 'react';
import {
Pressable,
StyleSheet,
Text,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import type { Palette } from '../../lib/kinetik/tokens/tokens';
export type SwipeActionTone = 'danger' | 'azure' | 'neutral' | 'success' | 'warning';
export type SwipeAction = {
key: string;
label: string;
/** Drawn above the label; receives the colour to use. */
icon?: (color: string) => ReactNode;
/** Defaults to `neutral`. */
tone?: SwipeActionTone;
/** Slide the row away and fold it shut before `onPress` runs, e.g. for Delete or Archive. */
dismiss?: boolean;
onPress: () => void;
};
export type SwipeRowProps = {
/** The row's content. */
children: ReactNode;
/** Names the row for screen readers; its actions are offered as custom actions. */
accessibilityLabel: string;
/** Revealed by swiping right. */
leading?: SwipeAction;
/** Revealed by swiping left, edge-most first. The first one runs on a full swipe. */
trailing?: readonly SwipeAction[];
/** Width of each action button. Defaults to 76. */
actionWidth?: number;
/** A long swipe runs the edge-most action. Defaults to true. */
fullSwipe?: boolean;
/** A tick when a full swipe arms. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
/** Share of the row's width past which a swipe runs the edge action. */
const FULL = 0.62;
function toneColors(tone: SwipeActionTone, c: Palette) {
switch (tone) {
case 'danger':
return { bg: c.danger, fg: c.background };
case 'azure':
return { bg: c.azure, fg: c.onAzure };
case 'success':
return { bg: c.success, fg: c.background };
case 'warning':
return { bg: c.warning, fg: c.background };
default:
return { bg: c.surfaceRaised, fg: c.text };
}
}
/**
* A list row with actions behind it. Swipe to reveal them; they share the
* space you open up. Swipe far enough and the edge action takes over the
* whole row with a tick, and letting go runs it, sliding the row away if
* it removes it.
*/
export function SwipeRow({
children,
accessibilityLabel,
leading,
trailing = [],
actionWidth = 76,
fullSwipe = true,
haptics = true,
testID,
style,
}: SwipeRowProps) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [open, setOpen] = useState<'leading' | 'trailing' | null>(null);
const x = useSharedValue(0);
const start = useSharedValue(0);
const armed = useSharedValue(0);
const armedAnim = useSharedValue(0);
const fold = useSharedValue(1);
const trailingW = trailing.length * actionWidth;
const leadingW = leading ? actionWidth : 0;
const close = () => {
x.set(withSpring(0, spring('smooth', reduced)));
setOpen(null);
};
const run = (action: SwipeAction, side: 'leading' | 'trailing') => {
setOpen(null);
if (!action.dismiss) {
x.set(withSpring(0, spring('smooth', reduced)));
armedAnim.set(withSpring(0, spring('smooth', reduced)));
action.onPress();
return;
}
if (reduced) {
action.onPress();
return;
}
// Slide off, then fold the row shut, then hand over to the list.
const away = side === 'trailing' ? -width : width;
x.set(withTiming(away, { duration: durations.base, easing: easings.exit }));
fold.set(
withTiming(0, { duration: durations.base, easing: easings.standard }, (finished) => {
if (finished) scheduleOnRN(action.onPress);
}),
);
};
const arm = (on: boolean) => {
if (on) haptic('tick');
};
const settle = (vx: number) => {
'worklet';
const pos = x.get();
const w = width;
if (fullSwipe && w > 0 && armed.get() === 1) {
// Marked as run, so the edge action keeps the whole row while it plays out.
armed.set(2);
const side = pos < 0 ? 'trailing' : 'leading';
const action = side === 'trailing' ? trailing[0] : leading;
if (action) {
scheduleOnRN(run, action, side);
return;
}
}
// Open if dragged past half the actions or flung that way; otherwise close.
if (pos < 0 && trailingW > 0 && (pos < -trailingW / 2 || vx < -600) && vx < 400) {
x.set(withSpring(-trailingW, spring('smooth', reduced)));
scheduleOnRN(setOpen, 'trailing');
} else if (pos > 0 && leadingW > 0 && (pos > leadingW / 2 || vx > 600) && vx > -400) {
x.set(withSpring(leadingW, spring('smooth', reduced)));
scheduleOnRN(setOpen, 'leading');
} else {
x.set(withSpring(0, spring('smooth', reduced)));
scheduleOnRN(setOpen, null);
}
};
const pan = Gesture.Pan()
.withTestId('swipe-row')
.activeOffsetX([-12, 12])
.failOffsetY([-10, 10])
.onBegin(() => start.set(x.get()))
.onUpdate((e) => {
let next = start.get() + e.translationX;
// No actions on a side: resist instead of opening.
if (next > 0 && !leading) next = Math.min(24, next * 0.15);
if (next < 0 && trailing.length === 0) next = Math.max(-24, next * 0.15);
x.set(next);
if (!fullSwipe || width === 0) return;
const on = Math.abs(next) > width * FULL ? 1 : 0;
if (on !== armed.get()) {
armed.set(on);
// The edge action grows over the others on a spring rather than jumping.
armedAnim.set(withSpring(on, spring('snappy', reduced)));
scheduleOnRN(arm, on === 1);
}
})
.onEnd((e) => settle(e.velocityX))
.onFinalize(() => {
if (armed.get() !== 2) armedAnim.set(withSpring(0, spring('snappy', reduced)));
armed.set(0);
});
const front = useAnimatedStyle(() => ({ transform: [{ translateX: x.get() }] }));
const shell = useAnimatedStyle(() => ({
height: height > 0 && fold.get() < 1 ? height * fold.get() : undefined,
opacity: fold.get() < 1 ? fold.get() : 1,
}));
const trailingBox = useAnimatedStyle(() => ({ width: Math.max(0, -x.get()) }));
const leadingBox = useAnimatedStyle(() => ({ width: Math.max(0, x.get()) }));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
const name = e.nativeEvent.actionName;
if (leading && name === leading.key) return run(leading, 'leading');
const action = trailing.find((a) => a.key === name);
if (action) run(action, 'trailing');
};
return (
<Animated.View
testID={testID}
style={[styles.shell, shell, style]}
onLayout={(e) => {
setWidth(e.nativeEvent.layout.width);
if (fold.get() === 1) setHeight(e.nativeEvent.layout.height);
}}
>
{trailing.length > 0 ? (
<Animated.View style={[styles.actions, styles.right, trailingBox]}>
{[...trailing].reverse().map((action, i, list) => (
<ActionButton
key={action.key}
action={action}
edge={i === list.length - 1}
count={list.length}
x={x}
armed={armedAnim}
colors={colors}
visible={open === 'trailing'}
onPress={() => run(action, 'trailing')}
/>
))}
</Animated.View>
) : null}
{leading ? (
<Animated.View style={[styles.actions, styles.left, leadingBox]}>
<ActionButton
action={leading}
edge
count={1}
x={x}
armed={armedAnim}
colors={colors}
visible={open === 'leading'}
onPress={() => run(leading, 'leading')}
/>
</Animated.View>
) : null}
<GestureDetector gesture={pan}>
<Animated.View
collapsable={false}
accessible
accessibilityLabel={accessibilityLabel}
accessibilityActions={[
...(leading ? [{ name: leading.key, label: leading.label }] : []),
...trailing.map((a) => ({ name: a.key, label: a.label })),
]}
onAccessibilityAction={onAccessibilityAction}
style={[{ backgroundColor: colors.surface }, front]}
>
{children}
{open ? (
<Pressable
onPress={close}
accessibilityRole="button"
accessibilityLabel="Close actions"
style={StyleSheet.absoluteFill}
/>
) : null}
</Animated.View>
</GestureDetector>
</Animated.View>
);
}
type ActionButtonProps = {
action: SwipeAction;
/** The edge-most action, which takes over on a full swipe. */
edge: boolean;
count: number;
x: SharedValue<number>;
armed: SharedValue<number>;
colors: Palette;
visible: boolean;
onPress: () => void;
};
function ActionButton({
action,
edge,
count,
x,
armed,
colors,
visible,
onPress,
}: ActionButtonProps) {
const { font } = useKinetikTheme();
const { bg, fg } = toneColors(action.tone ?? 'neutral', colors);
const style = useAnimatedStyle(() => {
const revealed = Math.abs(x.get());
const share = revealed / count;
const a = armed.get();
return { width: edge ? share + (revealed - share) * a : share * (1 - a) };
});
const content = useAnimatedStyle(() => ({
opacity: Math.min(1, Math.max(0, (Math.abs(x.get()) / count - 28) / 30)),
}));
return (
<Animated.View style={[styles.action, { backgroundColor: bg }, style]}>
<Pressable
onPress={onPress}
disabled={!visible}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={styles.actionPress}
>
<Animated.View style={[styles.actionContent, content]}>
{action.icon ? action.icon(fg) : null}
<Text
numberOfLines={1}
style={{ color: fg, fontSize: font.size.xs, fontWeight: font.weight.semibold }}
>
{action.label}
</Text>
</Animated.View>
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
shell: { overflow: 'hidden' },
actions: { position: 'absolute', top: 0, bottom: 0, flexDirection: 'row' },
right: { right: 0, justifyContent: 'flex-end' },
left: { left: 0 },
action: { overflow: 'hidden' },
actionPress: { flex: 1, justifyContent: 'center' },
actionContent: { alignItems: 'center', gap: 4, minWidth: 64, alignSelf: 'center' },
});Installation
npx kinetik-ui add swipe-rowpnpm dlx kinetik-ui add swipe-rowbunx kinetik-ui add swipe-rowyarn dlx kinetik-ui add swipe-rowThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/swipe-row.json
Install manually
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/swipe-row.tsx
Usage
import { SwipeRow } from '@/components/kinetik/swipe-row';
<SwipeRow
accessibilityLabel={`${mail.from}, ${mail.subject}`}
leading={{ key: 'pin', label: 'Pin', tone: 'azure', onPress: pin }}
trailing={[
{ key: 'delete', label: 'Delete', tone: 'danger', dismiss: true, onPress: remove },
{ key: 'flag', label: 'Flag', onPress: flag },
]}
>
<MailRow mail={mail} />
</SwipeRow>;trailing lists actions from the edge inwards. The first one is the edge action: it runs on a full swipe. Actions with dismiss slide the row away and fold it shut before onPress runs, so remove the item from your list in onPress.
When to use
- Quick actions on rows people process one by one: mail, notifications, tasks, saved items.
- Lists where the same actions are also available elsewhere, for example in the item's detail screen.
When not to use
- The only way to reach an action. Swipes are hard to discover.
- Rows inside a horizontal scroller or carousel, where the swipe would fight the scroll.
Notes
- A short swipe opens the actions; a fling opens them too. Tap the row to close them.
- Past 62% of the row's width the edge action grows over the others with a tick; let go there to run it. Turn this off with
fullSwipe={false}. - Swiping towards a side with no actions resists and springs back.
- Every action is a custom accessibility action on the row, so screen reader users never need to swipe.
Props
SwipeRowProps
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | The row's content. |
accessibilityLabelrequired | string | — | Names the row for screen readers; its actions are offered as custom actions. |
leading | SwipeAction | — | Revealed by swiping right. |
trailing | readonly SwipeAction[] | [] | Revealed by swiping left, edge-most first. The first one runs on a full swipe. |
actionWidth | number | 76 | Width of each action button. Defaults to 76. |
fullSwipe | boolean | true | A long swipe runs the edge-most action. Defaults to true. |
haptics | boolean | true | A tick when a full swipe arms. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- The row and its actions move on the UI thread from three shared values; a dismissed row folds its height once. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.90× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 154 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The row still follows the finger, but opens, closes and dismisses without springs or the slide-away.
- Screen readers
- The row is one element named by accessibilityLabel, with every action offered as a custom action.
- Touch target
- Action buttons are 76 pt wide and as tall as the row.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/swipe-row.tsx
import { useState, type ReactNode } from 'react';
import {
Pressable,
StyleSheet,
Text,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import type { Palette } from '../../lib/kinetik/tokens/tokens';
export type SwipeActionTone = 'danger' | 'azure' | 'neutral' | 'success' | 'warning';
export type SwipeAction = {
key: string;
label: string;
/** Drawn above the label; receives the colour to use. */
icon?: (color: string) => ReactNode;
/** Defaults to `neutral`. */
tone?: SwipeActionTone;
/** Slide the row away and fold it shut before `onPress` runs, e.g. for Delete or Archive. */
dismiss?: boolean;
onPress: () => void;
};
export type SwipeRowProps = {
/** The row's content. */
children: ReactNode;
/** Names the row for screen readers; its actions are offered as custom actions. */
accessibilityLabel: string;
/** Revealed by swiping right. */
leading?: SwipeAction;
/** Revealed by swiping left, edge-most first. The first one runs on a full swipe. */
trailing?: readonly SwipeAction[];
/** Width of each action button. Defaults to 76. */
actionWidth?: number;
/** A long swipe runs the edge-most action. Defaults to true. */
fullSwipe?: boolean;
/** A tick when a full swipe arms. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
/** Share of the row's width past which a swipe runs the edge action. */
const FULL = 0.62;
function toneColors(tone: SwipeActionTone, c: Palette) {
switch (tone) {
case 'danger':
return { bg: c.danger, fg: c.background };
case 'azure':
return { bg: c.azure, fg: c.onAzure };
case 'success':
return { bg: c.success, fg: c.background };
case 'warning':
return { bg: c.warning, fg: c.background };
default:
return { bg: c.surfaceRaised, fg: c.text };
}
}
/**
* A list row with actions behind it. Swipe to reveal them; they share the
* space you open up. Swipe far enough and the edge action takes over the
* whole row with a tick, and letting go runs it, sliding the row away if
* it removes it.
*/
export function SwipeRow({
children,
accessibilityLabel,
leading,
trailing = [],
actionWidth = 76,
fullSwipe = true,
haptics = true,
testID,
style,
}: SwipeRowProps) {
const { colors } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [open, setOpen] = useState<'leading' | 'trailing' | null>(null);
const x = useSharedValue(0);
const start = useSharedValue(0);
const armed = useSharedValue(0);
const armedAnim = useSharedValue(0);
const fold = useSharedValue(1);
const trailingW = trailing.length * actionWidth;
const leadingW = leading ? actionWidth : 0;
const close = () => {
x.set(withSpring(0, spring('smooth', reduced)));
setOpen(null);
};
const run = (action: SwipeAction, side: 'leading' | 'trailing') => {
setOpen(null);
if (!action.dismiss) {
x.set(withSpring(0, spring('smooth', reduced)));
armedAnim.set(withSpring(0, spring('smooth', reduced)));
action.onPress();
return;
}
if (reduced) {
action.onPress();
return;
}
// Slide off, then fold the row shut, then hand over to the list.
const away = side === 'trailing' ? -width : width;
x.set(withTiming(away, { duration: durations.base, easing: easings.exit }));
fold.set(
withTiming(0, { duration: durations.base, easing: easings.standard }, (finished) => {
if (finished) scheduleOnRN(action.onPress);
}),
);
};
const arm = (on: boolean) => {
if (on) haptic('tick');
};
const settle = (vx: number) => {
'worklet';
const pos = x.get();
const w = width;
if (fullSwipe && w > 0 && armed.get() === 1) {
// Marked as run, so the edge action keeps the whole row while it plays out.
armed.set(2);
const side = pos < 0 ? 'trailing' : 'leading';
const action = side === 'trailing' ? trailing[0] : leading;
if (action) {
scheduleOnRN(run, action, side);
return;
}
}
// Open if dragged past half the actions or flung that way; otherwise close.
if (pos < 0 && trailingW > 0 && (pos < -trailingW / 2 || vx < -600) && vx < 400) {
x.set(withSpring(-trailingW, spring('smooth', reduced)));
scheduleOnRN(setOpen, 'trailing');
} else if (pos > 0 && leadingW > 0 && (pos > leadingW / 2 || vx > 600) && vx > -400) {
x.set(withSpring(leadingW, spring('smooth', reduced)));
scheduleOnRN(setOpen, 'leading');
} else {
x.set(withSpring(0, spring('smooth', reduced)));
scheduleOnRN(setOpen, null);
}
};
const pan = Gesture.Pan()
.withTestId('swipe-row')
.activeOffsetX([-12, 12])
.failOffsetY([-10, 10])
.onBegin(() => start.set(x.get()))
.onUpdate((e) => {
let next = start.get() + e.translationX;
// No actions on a side: resist instead of opening.
if (next > 0 && !leading) next = Math.min(24, next * 0.15);
if (next < 0 && trailing.length === 0) next = Math.max(-24, next * 0.15);
x.set(next);
if (!fullSwipe || width === 0) return;
const on = Math.abs(next) > width * FULL ? 1 : 0;
if (on !== armed.get()) {
armed.set(on);
// The edge action grows over the others on a spring rather than jumping.
armedAnim.set(withSpring(on, spring('snappy', reduced)));
scheduleOnRN(arm, on === 1);
}
})
.onEnd((e) => settle(e.velocityX))
.onFinalize(() => {
if (armed.get() !== 2) armedAnim.set(withSpring(0, spring('snappy', reduced)));
armed.set(0);
});
const front = useAnimatedStyle(() => ({ transform: [{ translateX: x.get() }] }));
const shell = useAnimatedStyle(() => ({
height: height > 0 && fold.get() < 1 ? height * fold.get() : undefined,
opacity: fold.get() < 1 ? fold.get() : 1,
}));
const trailingBox = useAnimatedStyle(() => ({ width: Math.max(0, -x.get()) }));
const leadingBox = useAnimatedStyle(() => ({ width: Math.max(0, x.get()) }));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
const name = e.nativeEvent.actionName;
if (leading && name === leading.key) return run(leading, 'leading');
const action = trailing.find((a) => a.key === name);
if (action) run(action, 'trailing');
};
return (
<Animated.View
testID={testID}
style={[styles.shell, shell, style]}
onLayout={(e) => {
setWidth(e.nativeEvent.layout.width);
if (fold.get() === 1) setHeight(e.nativeEvent.layout.height);
}}
>
{trailing.length > 0 ? (
<Animated.View style={[styles.actions, styles.right, trailingBox]}>
{[...trailing].reverse().map((action, i, list) => (
<ActionButton
key={action.key}
action={action}
edge={i === list.length - 1}
count={list.length}
x={x}
armed={armedAnim}
colors={colors}
visible={open === 'trailing'}
onPress={() => run(action, 'trailing')}
/>
))}
</Animated.View>
) : null}
{leading ? (
<Animated.View style={[styles.actions, styles.left, leadingBox]}>
<ActionButton
action={leading}
edge
count={1}
x={x}
armed={armedAnim}
colors={colors}
visible={open === 'leading'}
onPress={() => run(leading, 'leading')}
/>
</Animated.View>
) : null}
<GestureDetector gesture={pan}>
<Animated.View
collapsable={false}
accessible
accessibilityLabel={accessibilityLabel}
accessibilityActions={[
...(leading ? [{ name: leading.key, label: leading.label }] : []),
...trailing.map((a) => ({ name: a.key, label: a.label })),
]}
onAccessibilityAction={onAccessibilityAction}
style={[{ backgroundColor: colors.surface }, front]}
>
{children}
{open ? (
<Pressable
onPress={close}
accessibilityRole="button"
accessibilityLabel="Close actions"
style={StyleSheet.absoluteFill}
/>
) : null}
</Animated.View>
</GestureDetector>
</Animated.View>
);
}
type ActionButtonProps = {
action: SwipeAction;
/** The edge-most action, which takes over on a full swipe. */
edge: boolean;
count: number;
x: SharedValue<number>;
armed: SharedValue<number>;
colors: Palette;
visible: boolean;
onPress: () => void;
};
function ActionButton({
action,
edge,
count,
x,
armed,
colors,
visible,
onPress,
}: ActionButtonProps) {
const { font } = useKinetikTheme();
const { bg, fg } = toneColors(action.tone ?? 'neutral', colors);
const style = useAnimatedStyle(() => {
const revealed = Math.abs(x.get());
const share = revealed / count;
const a = armed.get();
return { width: edge ? share + (revealed - share) * a : share * (1 - a) };
});
const content = useAnimatedStyle(() => ({
opacity: Math.min(1, Math.max(0, (Math.abs(x.get()) / count - 28) / 30)),
}));
return (
<Animated.View style={[styles.action, { backgroundColor: bg }, style]}>
<Pressable
onPress={onPress}
disabled={!visible}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={styles.actionPress}
>
<Animated.View style={[styles.actionContent, content]}>
{action.icon ? action.icon(fg) : null}
<Text
numberOfLines={1}
style={{ color: fg, fontSize: font.size.xs, fontWeight: font.weight.semibold }}
>
{action.label}
</Text>
</Animated.View>
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
shell: { overflow: 'hidden' },
actions: { position: 'absolute', top: 0, bottom: 0, flexDirection: 'row' },
right: { right: 0, justifyContent: 'flex-end' },
left: { left: 0 },
action: { overflow: 'hidden' },
actionPress: { flex: 1, justifyContent: 'center' },
actionContent: { alignItems: 'center', gap: 4, minWidth: 64, alignSelf: 'center' },
});