Impulse
Declarative gesture primitives for React Native. Impulse is a thin, ergonomic wrapper around react-native-gesture-handler (RNGH).
You write a gesture as an intent. You do not assemble it from a builder chain,
a useMemo, a ref dance, and hand-written translation maths.
useTap, useDoubleTap,
useLongPress, useDrag, usePan,
useSwipe, usePinch, and
useRotate are implemented. useHover and useEdgeSwipe are
designed and not written. Do not 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, and no physical device has run 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 is not the problem. RNGH exposes mechanisms on purpose — activation criteria, gesture relations, state machines — and 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
sharp edges wrong in its own way.
The same drag, twice
A horizontal drag that must let a vertical scroll view win. This is the case consumers 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),
})
scrollRef needs an as unknown as GestureReference cast in both snippets.
RNGH types a relation target as a ref to a component type, which is not what
ref={} produces, so a real ScrollView ref does not satisfy it. Impulse
mirrors RNGH's type exactly, so the mismatch stays visible instead of being
absorbed into a wider type that would stop catching upstream drift. This is a
known gap, not a decision.
scrollRef must also come from gesture-handler's ScrollView, not React
Native's. RNGH resolves a relation through ref.current?.handlerTag and
silently drops a ref that has none; Impulse warns in development when it
catches it. See Coexistence.
drag.x is a shared value that accumulates across gestures, so the
start.value dance 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 RNGH | What Impulse does |
|---|---|
Composition reads inside-out. Gesture.Simultaneous(Gesture.Race(tap, double), pan) has to be parsed 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 is itself composable. |
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 does not claim to solve this — it states it. |
What Impulse does not do
Impulse detects. It does not 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
has no business making 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, nothing happens and nothing
warns you — the gesture simply never fires.
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>
)
}
GestureDetector is re-exported from the package entry, because every hook's
result needs it. @rootnative/impulse stays your only gesture import.
Where it sits
Impulse is general-purpose. It is not built for, owned by, or coupled to
any consumer, including @rootnative/ui and @rootnative/inertia. 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, and Impulse will never require an animation library.
@rootnative/inertia-gestures ships a useDrag too, so that one name
collides. The products do not. That package requires @rootnative/inertia
and hands back an animatedStyle that already knows about bounds and
spring-back. Impulse requires no animation library and hands back the gesture
and its values. Reach for -gestures to make a Motion primitive follow a
finger. Reach for Impulse for the gesture itself.
Licence
MIT © RootNative.