Skip to main content

Impulse

Declarative gesture primitives for React Native. Impulse is a thin, ergonomic wrapper around react-native-gesture-handler (RNGH).

Impulse supports iOS and Android, and web is partial: four of the eight intents are verified in a browser. Electron, macOS, and Windows are not tested. See Platforms.

You write a gesture as an intent. You don't assemble it from a builder chain, a useMemo, a ref, and hand-written translation maths.

Alpha — eight intents ship today

useTap, useDoubleTap, useLongPress, useDrag, usePan, useSwipe, usePinch, and useRotate ship. useHover and useEdgeSwipe are designed and not written. Don't import them yet. See the Roadmap.

A device sweep on an Android emulator and an iOS simulator drove every intent on at least one platform. It proves the mechanics, not the feel. No physical device ran it. So each activation-criteria default is still a design intention. Every page states what the sweep proved and what is still unmeasured.

The problem​

RNGH is a good library, and it isn't the problem. RNGH exposes mechanisms on purpose: activation criteria, gesture relations, and state machines. It leaves every intent to you.

So each app rebuilds swipe-to-dismiss, double tap, long-press-then-drag, and pinch-about-a-focal-point from Pan plus arithmetic. Each rebuild gets the traps wrong in its own way.

The same drag, twice​

A horizontal drag that must let a vertical scroll view win. This is the case that apps get wrong most often.

// RNGH today
const x = useSharedValue(0)
const start = useSharedValue(0)

const pan = useMemo(
() =>
Gesture.Pan()
.activeOffsetX([-10, 10])
.failOffsetY([-5, 5])
.requireExternalGestureToFail(scrollRef)
.onStart(() => {
start.value = x.value
})
.onUpdate((e) => {
x.value = start.value + e.translationX
})
.onEnd((e) => {
runOnJS(commit)(e.translationX > 100)
}),
[],
)
// Impulse
const drag = useDrag({
axis: 'x',
deferTo: scrollRef,
onDragEnd: (e) => commit(e.translation.x > 100),
})
Two sharp edges Impulse does not absorb yet

scrollRef needs an as unknown as GestureReference cast in both snippets. RNGH types a relation target as a ref to a component type. ref={} doesn't produce that type, so a real ScrollView ref doesn't satisfy it. Impulse copies RNGH's type exactly, so the mismatch stays visible. A wider type would hide it, and would stop catching upstream drift. This is a known gap, not a decision.

scrollRef must also come from RNGH's LegacyScrollView, not from React Native's ScrollView or from Gesture Handler 3's ScrollView. RNGH resolves a relation through ref.current?.handlerTag. It drops a ref that has no tag, and it doesn't warn. Impulse warns in development when it catches this. See Coexistence.

drag.x is a shared value that accumulates across gestures, so the start.value step is gone. deferTo is the relation, named for what happens rather than for the method it calls. The scheduleOnRN boundary is Impulse's job, not yours.

What Impulse does differently​

Sharp edge in RNGHWhat Impulse does
Composition reads inside-out. You read Gesture.Simultaneous(Gesture.Race(tap, double), pan) backwards, and nesting grows with each gesture.Composition is data, not nesting: useGestures([tap, double, pan], { mode: 'race' }). Precedence reads left to right, and a composed result can compose again.
Coexisting with a scroll view needs one of three methods — simultaneousWithExternalGesture, blocksExternalGesture, requireExternalGestureToFail — with no hint about which one you want.Three named options on every hook: alongside runs together, blocks wins over, deferTo waits for the other to fail. Each maps to exactly one RNGH relation.
An inline callback changes the gesture's identity, and <GestureDetector> re-attaches it — mid-drag.Gesture identity is stable by construction. A JS-thread callback is never a gesture dependency. A test pins identity across an inline-callback re-render.
There is no swipe direction, no double tap, no pinch about a focal point. Each one is Pan plus maths you write again per project.One hook per intent. The intent is the primary surface, and the mechanism is the escape hatch.
Every semantic callback needs runOnJS, and a mistake fails on the UI thread with an unhelpful message.The callback name states its thread. onBegin, onUpdate, and onFinalize are worklets. onTap, onDragEnd, and onLongPress run on JS, and Impulse owns the boundary.
Payloads are flat: translationX, velocityY, absoluteX, focalX, numberOfPointers. You pick the right field per gesture from memory.Payloads are shaped per intent and grouped: { translation, velocity, absolute, settled } for a drag. The union of RNGH's fields is never the public type.
A gesture-only affordance is invisible to a screen reader and unreachable from a keyboard. No gesture library says so.Every intent page names its accessibility fallback. Impulse doesn't claim to solve this. It states it.

What Impulse does not do​

Impulse detects. It doesn't animate.

Every hook returns shared values and a gesture. Impulse starts no animation, owns no transition vocabulary, and has no spring config. You pass the values to the animation library you already use.

It also ships no components. There is no <Swipeable>, no drawer, and no carousel. A component makes layout and styling decisions, and a gesture library must not make them.

Install​

npm install @rootnative/impulse

Full setup, including the peers and the root view, is on the Installation page.

Impulse needs react-native-gesture-handler and react-native-reanimated as peers. Your app must render a <GestureHandlerRootView> above any component that uses a gesture. If that view is missing, the gesture never fires. In development on iOS and Android, Gesture Handler 3 throws. On web and in a release build, nothing warns you.

A first gesture​

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

function Card() {
const tap = useTap({ onTap: () => select() })

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

The package entry re-exports GestureDetector, because every hook's result needs it. @rootnative/impulse stays your only gesture import.

Where it sits​

Impulse is general-purpose. It isn't built for, owned by, or coupled to any consumer, including the other @rootnative packages. The @rootnative scope is a publishing namespace and nothing more.

@rootnative/inertia is the animation half of the pair: Impulse detects, Inertia animates. A bridge subpath is planned and not built. Impulse will never require an animation library.

@rootnative/inertia-gestures ships a useDrag too, so that one name collides. The products don't. That package requires @rootnative/inertia and returns an animatedStyle that already knows the bounds and the spring back. Impulse requires no animation library and returns the gesture and its values. Use -gestures to make a Motion.* component follow a finger. Use Impulse for the gesture itself.

Licence​

MIT © Root Native.