Swipeable list row that reveals left/right action buttons with threshold snapping and reveal haptics
components/ui/swipeable-row/swipeable-row.tsx@lumioui/core@latestreact-native-reanimated@^4.0.0react-native-gesture-handler@^2.20.0expo-haptics@*| leftActions? | SwipeableAction[] | Actions revealed when swiping right (shown on the left edge). |
| rightActions? | SwipeableAction[] | Actions revealed when swiping left (shown on the right edge). |
| actionWidth? | number | Width per action button in px. Default 80. |
| children? | React.ReactNode | |
| className? | string | |
| style? | StyleProp<ViewStyle> |
Swipeable list row that reveals left/right action buttons with threshold snapping and reveal haptics
import { SwipeableRow } from "@/components/ui/swipeable-row/swipeable-row";
export function Example() {
return (
<SwipeableRow />
);
} import * as React from "react";
import { forwardRef, useMemo } from "react";
import { Text, View, type AccessibilityActionEvent, type AccessibilityActionInfo, type StyleProp, type ViewStyle, } from "react-native";
import Animated, {
interpolate,
runOnJS,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withSpring,
type SharedValue,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { cn, spring, useHaptic, useReducedMotion, usePressAnimation, AnimatedPressable } from "@lumioui/core";
export interface SwipeableAction {
label: string;
/** NativeWind-friendly hex/token color applied to the action background. */
color: string;
onPress: () => void;
/** Disabled actions render dimmed, expose the disabled state, and no-op with a warning haptic. */
disabled?: boolean;
/**
* Optional label classes. The label defaults to `text-white` because
* action backgrounds are saturated consumer colors — override only if
* your `color` is light enough that white text loses contrast.
*/
labelClassName?: string;
}
export interface SwipeableRowProps {
/** Actions revealed when swiping right (shown on the left edge). */
leftActions?: SwipeableAction[];
/** Actions revealed when swiping left (shown on the right edge). */
rightActions?: SwipeableAction[];
/** Width per action button in px. Default 80. */
actionWidth?: number;
children?: React.ReactNode;
className?: string;
style?: StyleProp<ViewStyle>;
}
/**
* SwipeableRow — PRO. Pan horizontally to reveal action buttons under the row.
* Snaps open past half the action width; haptic fires once per reveal.
* Requires react-native-gesture-handler (dev build, not Expo Go).
*/
/** Past the action extents the row rubber-bands — iOS-style diminishing
* resistance (~1/3 rate, capped) so the edge feels elastic, not a wall. */
const RUBBER_BAND = 0.3;
const RUBBER_BAND_MAX = 36;
interface SwipeActionButtonProps {
action: SwipeableAction;
actionWidth: number;
/** Button index within its side — drives the staggered parallax reveal. */
index: number;
/** 1 = actions enter from the right edge, -1 = from the left. */
side: 1 | -1;
/** Reveal progress 0..1 for this side, derived from row position. */
reveal: SharedValue<number>;
onPress: () => void;
}
/** Single revealed action — own component so each button gets its own press
* animation and slides in with a staggered parallax as the row opens. */
function SwipeActionButton({ action, actionWidth, index, side, reveal, onPress }: SwipeActionButtonProps) {
// Opacity wash keeps the legacy pressed feedback; scale is the standard press feel.
const press = usePressAnimation({ pressedOpacity: 0.7 });
const reduce = useReducedMotion();
const handlePressIn = () => {
press.onPressIn();
};
const handlePressOut = () => {
press.onPressOut();
};
const parallaxStyle = useAnimatedStyle(() => ({
transform: [
{
// Outer actions trail further — a fan-out stagger while revealing.
// Reduced motion drops the parallax: buttons fade in place instead.
translateX: reduce ? 0 : (1 - reveal.value) * actionWidth * (index + 1) * side,
},
],
}));
return (
<AnimatedPressable
onPress={onPress}
onPressIn={action.disabled ? undefined : handlePressIn}
onPressOut={action.disabled ? undefined : handlePressOut}
style={[
{ width: actionWidth, backgroundColor: action.color, minHeight: 44 },
parallaxStyle,
press.animatedStyle,
action.disabled && { opacity: 0.5 },
]}
className="items-center justify-center overflow-hidden"
accessibilityRole="button"
accessibilityLabel={action.label}
accessibilityState={{ disabled: !!action.disabled }}
>
{/* White label default: action backgrounds are saturated consumer colors
where semantic tokens can't apply — override via `labelClassName`. */}
<Text
className={cn("text-sm font-semibold text-white", action.labelClassName)}
numberOfLines={1}
>
{action.label}
</Text>
</AnimatedPressable>
);
}
export const SwipeableRow = forwardRef<View, SwipeableRowProps>(function SwipeableRow(
{ leftActions = [], rightActions = [], actionWidth = 80, children, className, style },
ref
) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const translateX = useSharedValue(0);
const startX = useSharedValue(0);
const revealed = useSharedValue(false);
// JS-side mirror so we can disable invisible action hit targets.
const [actionsOpen, setActionsOpen] = React.useState(false);
const maxLeft = leftActions.length * actionWidth;
const maxRight = rightActions.length * actionWidth;
const snap = React.useCallback(
(open: boolean) => {
if (open) haptics.light();
},
[haptics]
);
const pan = useMemo(
() =>
Gesture.Pan()
.activeOffsetX([-10, 10])
.failOffsetY([-15, 15])
.onStart(() => {
"worklet";
startX.value = translateX.value;
})
.onUpdate((e) => {
"worklet";
const next = startX.value + e.translationX;
// Rubber-band beyond the action extents — 1/3 rate, capped.
let clamped = next;
if (next > maxLeft) {
clamped = maxLeft + Math.min(RUBBER_BAND_MAX, (next - maxLeft) * RUBBER_BAND);
} else if (next < -maxRight) {
clamped = -maxRight - Math.min(RUBBER_BAND_MAX, (-maxRight - next) * RUBBER_BAND);
}
translateX.value = clamped;
const isRevealing = Math.abs(clamped) > Math.max(maxLeft, maxRight) / 2;
if (isRevealing && !revealed.value) {
revealed.value = true;
runOnJS(snap)(true);
} else if (!isRevealing) {
revealed.value = false;
}
})
.onEnd((e) => {
"worklet";
const projected = translateX.value + e.velocityX * 0.1;
if (projected > maxLeft / 2 && maxLeft > 0) {
translateX.value = reduce ? maxLeft : withSpring(maxLeft, spring.layout);
runOnJS(setActionsOpen)(true);
} else if (projected < -maxRight / 2 && maxRight > 0) {
translateX.value = reduce ? -maxRight : withSpring(-maxRight, spring.layout);
runOnJS(setActionsOpen)(true);
} else {
translateX.value = reduce ? 0 : withSpring(0, spring.layout);
runOnJS(setActionsOpen)(false);
}
}),
[translateX, startX, revealed, maxLeft, maxRight, snap, reduce]
);
const rowStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const leftStyle = useAnimatedStyle(() => ({
opacity: interpolate(translateX.value, [0, Math.max(maxLeft, 1)], [0, 1], "clamp"),
}));
const rightStyle = useAnimatedStyle(() => ({
opacity: interpolate(translateX.value, [-Math.max(maxRight, 1), 0], [1, 0], "clamp"),
}));
// Reveal progress 0..1 per side — drives the staggered parallax on each
// action button as the row opens.
const leftReveal = useDerivedValue(() =>
maxLeft > 0 ? Math.max(0, Math.min(1, translateX.value / maxLeft)) : 0
);
const rightReveal = useDerivedValue(() =>
maxRight > 0 ? Math.max(0, Math.min(1, -translateX.value / maxRight)) : 0
);
const invokeAction = React.useCallback(
(action: SwipeableAction) => {
if (action.disabled) {
haptics.warning();
return;
}
haptics.selection();
translateX.value = reduce ? 0 : withSpring(0, spring.layout);
revealed.value = false;
setActionsOpen(false);
action.onPress();
},
[haptics, reduce, translateX, revealed]
);
// Rotor actions: while the row is closed the revealed buttons are pulled
// from the a11y tree, so VoiceOver/TalkBack users get a "reveal" action
// plus a direct invoke action per button instead.
const a11yActions = React.useMemo<AccessibilityActionInfo[]>(() => {
const list: AccessibilityActionInfo[] = [];
if (leftActions.length + rightActions.length > 0) {
list.push({ name: "reveal", label: "Reveal actions" });
}
leftActions.forEach((action, i) =>
list.push({ name: `invoke-left-${i}`, label: action.label })
);
rightActions.forEach((action, i) =>
list.push({ name: `invoke-right-${i}`, label: action.label })
);
return list;
}, [leftActions, rightActions]);
const onAccessibilityAction = React.useCallback(
(event: AccessibilityActionEvent) => {
const name = event.nativeEvent.actionName;
if (name === "reveal") {
// Reveal the trailing (right) edge first — the iOS convention —
// falling back to the leading edge when only left actions exist.
if (maxRight > 0) {
translateX.value = reduce ? -maxRight : withSpring(-maxRight, spring.layout);
} else if (maxLeft > 0) {
translateX.value = reduce ? maxLeft : withSpring(maxLeft, spring.layout);
} else {
return;
}
revealed.value = true;
setActionsOpen(true);
haptics.light();
return;
}
const match = /^invoke-(left|right)-(\d+)$/.exec(name);
if (!match) return;
const action = (match[1] === "left" ? leftActions : rightActions)[Number(match[2])];
if (action) invokeAction(action);
},
[leftActions, rightActions, maxLeft, maxRight, reduce, haptics, translateX, revealed, invokeAction]
);
const renderActions = (actions: SwipeableAction[], side: "left" | "right") =>
actions.map((action, i) => (
<SwipeActionButton
key={`${side}-${i}`}
action={action}
actionWidth={actionWidth}
index={i}
side={side === "right" ? 1 : -1}
reveal={side === "right" ? rightReveal : leftReveal}
onPress={() => invokeAction(action)}
/>
));
return (
<View
ref={ref}
className={cn("overflow-hidden", className)}
style={style}
accessibilityState={{ expanded: actionsOpen }}
accessibilityHint={a11yActions.length > 0 ? "Swipe to reveal actions" : undefined}
accessibilityActions={a11yActions}
onAccessibilityAction={onAccessibilityAction}
>
{maxLeft > 0 && (
<Animated.View
style={[leftStyle, { width: maxLeft }]}
className="absolute bottom-0 left-0 top-0 flex-row"
pointerEvents={actionsOpen ? "auto" : "none"}
accessibilityElementsHidden={!actionsOpen}
importantForAccessibility={actionsOpen ? "auto" : "no-hide-descendants"}
>
{renderActions(leftActions, "left")}
</Animated.View>
)}
{maxRight > 0 && (
<Animated.View
style={[rightStyle, { width: maxRight }]}
className="absolute bottom-0 right-0 top-0 flex-row"
pointerEvents={actionsOpen ? "auto" : "none"}
accessibilityElementsHidden={!actionsOpen}
importantForAccessibility={actionsOpen ? "auto" : "no-hide-descendants"}
>
{renderActions(rightActions, "right")}
</Animated.View>
)}
<GestureDetector gesture={pan}>
<Animated.View style={rowStyle} className="bg-background" collapsable={false}>
{children}
</Animated.View>
</GestureDetector>
</View>
);
});
SwipeableRow.displayName = "SwipeableRow";