Skip to main content

useTap

Recognize a single tap.

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

const tap = useTap({ onTap: () => select(item.id) })

return (
<GestureDetector gesture={tap.gesture}>
<View style={styles.card} />
</GestureDetector>
)

Options​

OptionTypeDefaultWhat it does
pointersnumber1How many fingers must be down. A two-finger tap is the same intent with a different count.
maxDurationnumber500How long the finger may stay down, in ms. Past it the gesture fails rather than firing, which leaves the touch to a long press racing against it.
maxDistancenumber10How far the finger may travel, in points. Raising it makes the tap forgiving and makes it harder for a drag in the same view to win.
hitSlopHitSlop—Extra touchable area. An inline object is fine; the gesture is not rebuilt when the contents are unchanged.
enabledbooleantruePrefer this over unmounting the detector — a disabled gesture keeps its identity and its relations.
onTap(event, { cancelled }) => void—JS thread. The tap ended. cancelled says how — see below.
onBegin(event) => void—Worklet. The finger went down and the gesture is a candidate.
onFinalize(event, success) => void—Worklet. The gesture is over, recognized or not.

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

Callbacks and threads​

onTap is an ordinary function. Set React state in it directly — Impulse owns the boundary.

onBegin and onFinalize are worklets. Mark them with 'worklet', and keep their identity stable, because a worklet is a direct gesture dependency. See Threads and callbacks.

Being a candidate is not winning

onBegin fires on touch-down. In a race with a long press or a drag, the gesture may still fail afterwards. Use onBegin to show a pressed state, and undo it in onFinalize, which runs on both paths.

The cancel path​

Every end callback fires on both endings, and the second argument says which:

interface IntentEndInfo {
cancelled: boolean
}

cancelled is true when the system took the gesture away instead of the user completing it — a competing gesture in a relation won, or the app went to the background. It is false for the ordinary ending.

Read it before you act. A handler that navigates, submits, or counts should do nothing when it is true.

A gesture that never activated reaches neither ending. It goes to onFinalize with success: false and stops there, so cancelled never announces the end of something that never started.

A touch that moved past maxDistance or stayed down past maxDuration was never a tap at all. It does not reach onTap on either path — it reaches onFinalize with success: false and stops there.

const tap = useTap({
onTap: (event, { cancelled }) => {
if (cancelled) return
select(item.id)
},
})

Payload​

Both tap hooks share one TapEvent, because they recognize the same touch and differ only in how many times it happens.

interface TapEvent {
x: number // relative to the view the gesture is attached to
y: number
absolute: Point // relative to the window
pointers: number // fingers down at recognition
}

Prefer absolute when the view itself is being transformed by a gesture. A tap on a view that is mid-animation reports a moving x.

Result​

const tap = useTap({ onTap: select })

tap.gesture // hand to <GestureDetector>
tap.ref // name it in another hook's alongside / blocks / deferTo
tap.isActive // SharedValue<boolean>

isActive is true while the finger is down, set at onBegin. That makes it a real pressed state:

const style = useAnimatedStyle(() => ({
opacity: tap.isActive.value ? 0.6 : 1,
}))
isActive means something different in other hooks

useTap sets it at touch-down. useLongPress and useDrag set it at recognition. Check each hook's page before driving UI from it.

Activation criteria​

maxDuration is RNGH's own default, restated so it cannot move underneath Impulse in an RNGH release.

maxDistance is Impulse's number, not RNGH's. RNGH defers the slop to the platform, so the same tap is accepted on one operating system and rejected on the other. A fixed number is what a consumer can reason about. 10 points is roughly a finger's own jitter while pressing.

Neither default has been measured

The device sweep of 2026-09-19 did not test either value. It tested the race between a tap and a double tap, which passed on both platforms — see useDoubleTap. Both defaults are design intentions. maxDistance is shared with useDoubleTap on purpose, so moving it moves both.

Pairing with a double tap​

A single tap and a double tap on one view is a composition, not an option, and the mode matters:

const double = useDoubleTap({ onDoubleTap: zoomIn })
const tap = useTap({ onTap: select })

const { gesture } = useGestures([double, tap], { mode: 'exclusive' })

race is the wrong mode and fails quietly. A single tap recognizes on the first release, so it wins every race and the double tap never fires. exclusive makes the single tap wait to learn whether a second tap is coming.

The cost is latency: the single tap cannot report for maxDelay milliseconds. Lower maxDelay rather than building the pair by hand.

Web​

Verified working. RNGH recognizes a tap from pointer events, and a single-finger tap behaves as it does on native.

pointers above 1 is unreliable on web: a mouse reports one pointer, and touch emulation varies by browser. See Web behaviour.

Accessibility​

A tap gesture is invisible to a screen reader and unreachable from a keyboard. This hook does not fix that, and it cannot.

Whatever the tap does must be reachable another way:

  • put the same action on a <Pressable>, or
  • declare it with accessibilityActions and onAccessibilityAction on the view the gesture is attached to.

A tap-only affordance is a bug, not a trade-off.

A plain tap rarely needs this hook

If all you need is "run this when the user taps", <Pressable> already does it and is accessible by default. Reach for useTap when you need the tap to compose — to race a long press, defer to a scroll view, or drive a pressed state on the UI thread.