Skip to main content

useSwipe

Recognize a directional swipe.

import { GestureDetector, useSwipe } from '@rootnative/impulse'

const swipe = useSwipe({
directions: ['left'],
onSwipe: () => archive(item.id),
onSwipeEnd: () => {
swipe.x.value = withSpring(0)
},
})

return (
<GestureDetector gesture={swipe.gesture}>
<Animated.View style={style} />
</GestureDetector>
)

A swipe is a pan that is judged at release. The finger moves, x and y report it so the view can follow, and on release the travel and the speed along the dominant axis decide whether it counted.

Options​

OptionTypeDefaultWhat it does
directionsSwipeDirection[]all fourWhich directions may commit — and the activation criterion. See below.
thresholdnumber10How far the finger travels before the swipe activates and starts reporting.
commitDistancenumber80How far a release must have travelled to count.
commitSpeednumber800How fast a release must be moving to count. The flick.
failOffsetnumber—Cross-axis movement that makes the swipe give up. Ignored for a mixed directions list.
pointersnumber—Unset leaves RNGH's one-to-ten range.
hitSlopHitSlop—Extra touchable area.
enabledbooleantrueKeeps identity and relations while off.
onSwipe(event) => void—JS thread. The swipe committed. event.direction is never null.
onSwipeEnd(event, { cancelled }) => void—JS thread. Every release of an activated swipe, committed or not.
onBegin(event) => void—Worklet. Touch down, candidate only.
onUpdate(event) => void—Worklet. Every frame. direction is null on all of them.
onFinalize(event, success) => void—Worklet. Over, activated or not.

Every hook also takes alongside, blocks, deferTo, and testId. See Coexistence.

directions is the coexistence setting, not only a filter​

This is the part that is easy to read as a filter and is not one.

directionsActivation criterionShares a view with a vertical list?
all horizontaldirectional on xYes, with no relation declared
all verticaldirectional on yYes, against a horizontal pager
mixedradialNo — declare deferTo or blocks
// The list still scrolls: vertical movement never reaches the x offset.
const card = useSwipe({ directions: ['left', 'right'], onSwipe: dismiss })

Narrow directions even when the extra directions would never fire. It is the cheapest coexistence fix there is.

Two thresholds, and they mean different things​

threshold is when the swipe takes the touch and x and y start reporting. commitDistance and commitSpeed are what the release is measured against.

A swipe that activates and stops short is a successful gesture with no direction. It reaches onSwipeEnd with direction: null, and never reaches onSwipe.

Either commit test is enough on its own: far enough, or fast enough.

commitSpeed is untested, and neither number has a feel result

Too low and a card leaves on a nudge. Too high and a deliberate flick does nothing. commitDistance passed the device sweep of 2026-09-19 on an Android emulator and an iOS simulator: a long swipe committed, and a short, slow one did not. commitSpeed is not tested. The iOS simulator delivers about 304 points per second, and the Android emulator stops near 690 before it merges the input events. A physical device is necessary. example/screens/SwipeScreen.tsx is where that gets answered.

Payload​

interface SwipeEvent {
direction: SwipeDirection | null // null = the release did not commit
distance: number // along the dominant axis, always positive
speed: number // along the dominant axis, always positive
translation: Point // signed, per axis, since activation
velocity: Point // signed, per axis
absolute: Point
pointers: number
}

onSwipe receives a CommittedSwipeEvent — the same payload with direction narrowed to a real direction, which saves that callback a null check for a case it cannot be in.

distance and speed are measured from the activation point, so the threshold travel does not count towards commitDistance.

The dominant axis decides, alone​

The dominant axis is the one the finger travelled furthest along. Only that axis is tested — a release is one swipe, not two.

A dominant axis whose direction is not allowed does not fall back to the other axis. A release that went mostly down is not a left swipe, however far sideways it also drifted.

onSwipe and onSwipeEnd do different jobs​

const swipe = useSwipe({
directions: ['left', 'right'],
// Only for a release that counted. This is where the action goes.
onSwipe: (event) => remove(item.id, event.direction),
// Every release. This is where the view is put somewhere.
onSwipeEnd: (event) => {
swipe.x.value = withSpring(
event.direction === 'left' ? -520 : event.direction === 'right' ? 520 : 0,
)
},
})

onSwipe says to act. onSwipeEnd says to put the view back — or to send it off the screen.

x and y are movement, not a position​

Zero at the start of every gesture, like usePan. They keep their final number after the gesture ends, because Impulse does not put them back — that is an animation, and Impulse owns no animation vocabulary.

No style, and no animation

@rootnative/inertia-gestures ships a useSwipe that owns the snap-back and the commit exit, and it requires @rootnative/inertia to do it. Reach for that one when a Motion.View should follow a finger and spring home. Reach for this one when the numbers are what you want.

The cancel path​

A cancelled gesture never commits. The finger did not lift, so the velocity describes the last movement rather than a release, and acting on it would commit a swipe the user never finished.

onSwipe does not fire. onSwipeEnd fires with direction: null and cancelled: true.

Web​

RNGH recognizes pan from pointer events, so a mouse drag commits the same way a touch does, and speed is reported in the same units.

A trackpad's two-finger swipe is a scroll, not a pan, and never reaches this hook.

Accessibility​

A swipe is invisible to a screen reader and unreachable from a keyboard.

Whatever it commits must be reachable another way:

  • a visible button for a row action
  • a paging control for a carousel
  • accessibilityActions with onAccessibilityAction

A swipe-only action is a bug, not a trade-off.