Low-level, multi-touch pointer events for React Native — bypasses the JS responder system entirely, enabling simultaneous input from multiple native views (joysticks, buttons, canvases) without blocking each other.
Built on New Architecture (Fabric) with native Kotlin (Android) and Objective-C++ (iOS) implementations.
- Features
- Requirements
- Installation
- Quick Start
- Usage Examples
- API Reference
- How It Works
- Multi-Touch Behaviour
- Contributing
- License
- ✅ True multi-touch — each
RawPointerViewtracks its own touch independently via Android split-motion dispatch and iOS multi-touch - ✅ Bypasses JS responder — no single-responder blocking; joystick + buttons work simultaneously
- ✅ dp coordinates — all coordinates (
x,y,globalX,globalY) emitted in logical pixels (dp on Android, points on iOS), consistent with React Native's layout system - ✅ Spurious-event deduplication — Android
ACTION_MOVEevents are filtered per-pointer; stationary fingers do not re-fireonRawPointerMove - ✅ Fabric-native dispatch — events dispatched directly via JSI (no bridge hop); full event payload delivered via
getEventData()override - ✅ Zero dependencies — no additional native dependencies beyond React Native itself
| Minimum | |
|---|---|
| React Native | 0.75 (New Architecture required) |
| React | 18+ |
| Android | API 21+ |
| iOS | 14.0+ |
| Architecture | New Architecture only |
⚠️ New Architecture required. This library uses Fabric view components and JSI event dispatch. Old Architecture (bridge mode) is not supported.
# npm
npm install react-native-raw-pointer
# yarn
yarn add react-native-raw-pointerNo additional steps — auto-linked by React Native.
cd ios && pod installimport { RawPointerView } from 'react-native-raw-pointer';
import type { RawPointerEvent } from 'react-native-raw-pointer';
export default function App() {
return (
<RawPointerView
style={{ width: 200, height: 200, backgroundColor: '#1e1e2e' }}
behavior="opaque"
onRawPointerDown={(e: RawPointerEvent) =>
console.log(`finger ${e.pointerId} down at (${e.x.toFixed(1)}, ${e.y.toFixed(1)})`)
}
onRawPointerMove={(e: RawPointerEvent) =>
console.log(`finger ${e.pointerId} at (${e.x.toFixed(1)}, ${e.y.toFixed(1)})`)
}
onRawPointerUp={(e: RawPointerEvent) =>
console.log(`finger ${e.pointerId} up`)
}
/>
);
}A fully-featured virtual joystick with spring-return animation and unit-circle clamping.
import { useRef, useCallback } from 'react';
import { Animated, View } from 'react-native';
import { RawPointerView } from 'react-native-raw-pointer';
import type { RawPointerEvent } from 'react-native-raw-pointer';
const SIZE = 140; // diameter of the joystick base (dp)
const KNOB = 52; // diameter of the draggable knob (dp)
const RADIUS = (SIZE - KNOB) / 2; // maximum knob travel
const CENTER = SIZE / 2;
interface JoystickProps {
onVector: (x: number, y: number) => void; // x, y in -1..1
}
export function Joystick({ onVector }: JoystickProps) {
const knobX = useRef(new Animated.Value(0)).current;
const knobY = useRef(new Animated.Value(0)).current;
const activeId = useRef<number | null>(null);
const handleDown = useCallback((e: RawPointerEvent) => {
if (activeId.current !== null) return; // claim only the first finger
activeId.current = e.pointerId;
onVector(0, 0);
}, [onVector]);
const handleMove = useCallback((e: RawPointerEvent) => {
if (e.pointerId !== activeId.current) return;
const rawX = e.x - CENTER;
const rawY = e.y - CENTER;
const dist = Math.sqrt(rawX * rawX + rawY * rawY);
const scale = dist > RADIUS ? RADIUS / dist : 1;
knobX.setValue(Math.max(-RADIUS, Math.min(RADIUS, rawX)));
knobY.setValue(Math.max(-RADIUS, Math.min(RADIUS, rawY)));
onVector((rawX * scale) / RADIUS, (rawY * scale) / RADIUS);
}, [knobX, knobY, onVector]);
const handleUp = useCallback((e: RawPointerEvent) => {
if (e.pointerId !== activeId.current) return;
activeId.current = null;
Animated.spring(knobX, { toValue: 0, useNativeDriver: true }).start();
Animated.spring(knobY, { toValue: 0, useNativeDriver: true }).start();
onVector(0, 0);
}, [knobX, knobY, onVector]);
return (
<RawPointerView
behavior="opaque"
style={{ width: SIZE, height: SIZE, borderRadius: SIZE / 2 }}
onRawPointerDown={handleDown}
onRawPointerMove={handleMove}
onRawPointerUp={handleUp}
onRawPointerCancel={handleUp}
>
<Animated.View
style={[
{ width: KNOB, height: KNOB, borderRadius: KNOB / 2, backgroundColor: '#6366f1' },
{ transform: [{ translateX: knobX }, { translateY: knobY }] },
]}
/>
</RawPointerView>
);
}Using RawPointerView for buttons bypasses the JS single-responder system, allowing buttons to respond while a joystick is held.
Why not
TouchableOpacity?
TouchableOpacityuses React Native's JS responder, which is exclusive — only one view can be the active responder at a time. When a joystick holds the responder,TouchableOpacitybuttons are blocked.RawPointerViewdispatches natively, so both work in parallel.
import { useRef, useCallback } from 'react';
import { Animated, Text, StyleSheet } from 'react-native';
import { RawPointerView } from 'react-native-raw-pointer';
interface NativeButtonProps {
label: string;
onPress: () => void;
}
export function NativeButton({ label, onPress }: NativeButtonProps) {
const scale = useRef(new Animated.Value(1)).current;
const opacity = useRef(new Animated.Value(1)).current;
const handleDown = useCallback(() => {
Animated.parallel([
Animated.spring(scale, { toValue: 0.92, useNativeDriver: true }),
Animated.timing(opacity, { toValue: 0.65, duration: 60, useNativeDriver: true }),
]).start();
onPress();
}, [scale, opacity, onPress]);
const handleRelease = useCallback(() => {
Animated.parallel([
Animated.spring(scale, { toValue: 1, useNativeDriver: true }),
Animated.timing(opacity, { toValue: 1, duration: 100, useNativeDriver: true }),
]).start();
}, [scale, opacity]);
return (
<RawPointerView
behavior="opaque"
onRawPointerDown={handleDown}
onRawPointerUp={handleRelease}
onRawPointerCancel={handleRelease}
>
<Animated.View style={[styles.btn, { transform: [{ scale }], opacity }]}>
<Text style={styles.label}>{label}</Text>
</Animated.View>
</RawPointerView>
);
}
const styles = StyleSheet.create({
btn: { paddingVertical: 8, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#1e293b' },
label: { color: '#f8fafc', fontWeight: '700' },
});| Prop | Type | Default | Description |
|---|---|---|---|
behavior |
PointerBehavior |
'opaque' |
Controls how touches are dispatched |
onRawPointerDown |
(e: RawPointerEvent) => void |
— | Fires when a finger touches down within bounds |
onRawPointerMove |
(e: RawPointerEvent) => void |
— | Fires as an active finger moves |
onRawPointerUp |
(e: RawPointerEvent) => void |
— | Fires when a finger lifts |
onRawPointerCancel |
(e: RawPointerEvent) => void |
— | Fires when the system cancels a touch sequence |
All standard ViewProps are also accepted (style, children, testID, etc.).
interface RawPointerEvent {
/**
* Stable finger identity for this gesture's lifetime.
* Reused after all fingers lift. Use to correlate DOWN → MOVE → UP events
* for the same finger.
*/
pointerId: number;
/** X position relative to this view's top-left corner, in dp */
x: number;
/** Y position relative to this view's top-left corner, in dp */
y: number;
/** Change in X since the last event for this pointer (0 on DOWN) */
dx: number;
/** Change in Y since the last event for this pointer (0 on DOWN) */
dy: number;
/** X position relative to the screen top-left, in dp */
globalX: number;
/** Y position relative to the screen top-left, in dp */
globalY: number;
/**
* Touch pressure, 0..1.
* Defaults to 1.0 on devices that don't report pressure (most iOS devices).
*/
pressure: number;
/** Timestamp in milliseconds since Unix epoch */
timestamp: number;
}Coordinate space: All coordinates (
x,y,globalX,globalY,dx,dy) are in logical pixels — the same unit React Native uses for layout (width,height,StyleSheetvalues). On Android this is dp (density-independent pixels); on iOS this is UIKit points.
type PointerBehavior = 'opaque' | 'transparent';| Value | Behaviour |
|---|---|
'opaque' |
(default) Captures all touches within bounds. Views underneath do not receive the same touches. Use for joysticks, sliders, canvas surfaces. |
'transparent' |
Does not participate in touch dispatch. All callbacks are silenced. Views underneath receive touches normally. Use to temporarily disable a region. |
React Native's responder system (PanResponder, TouchableOpacity, Pressable, GestureDetector) routes all touches through a single JS responder. Only one view can hold the responder at a time. When a joystick claims it, all other touchables become unresponsive.
RawPointerView processes and dispatches touches entirely in native code, bypassing the JS responder:
User touches screen
│
▼
Android ViewGroup (split-motion dispatch)
├── Finger 1 → Joystick A (RawPointerView) ← native claim, no JS involved
├── Finger 2 → Joystick B (RawPointerView) ← native claim, no JS involved
└── Finger 3 → Button (RawPointerView) ← native claim, no JS involved
│
Events dispatched via JSI to JS
(all three simultaneously ✅)
| File | Role |
|---|---|
RawPointerView.kt |
FrameLayout subclass; intercepts MotionEvent, converts to dp, deduplicates stationary pointers |
RawPointerEvent.kt |
Extends Fabric Event<T>; overrides getEventData() to supply payload to C++ Fabric dispatcher |
RawPointerViewManager.kt |
ViewGroupManager registration with Fabric runtime |
Key design decisions:
- Extends
FrameLayout(notView) so React Native children render normally inside - Overrides
onInterceptTouchEventto capture touches before they reach children - Calls
requestDisallowInterceptTouchEvent(true)to prevent ancestors (ScrollView, root) from stealing the touch stream - Converts
MotionEventphysical pixels → dp viaDisplayMetrics.density - Skips
onRawPointerMovefor pointers withdx == 0 && dy == 0(avoids cross-pollination when another finger triggersACTION_MOVE)
| File | Role |
|---|---|
RawPointerView.mm |
UIView subclass with multipleTouchEnabled = YES; uses UITouch* pointer identity as stable pointerId; emits via Fabric C++ EventEmitter |
Key design decisions:
exclusiveTouch = NOallows other views to receive touches simultaneouslytouchesMoved:is called only with touches that actually moved — no deduplication needed- UIKit
locationInView:returns points (logical pixels) natively — no pixel conversion needed
| File | Role |
|---|---|
RawPointerViewNativeComponent.ts |
Codegen spec — defines props and events for Fabric runtime |
RawPointerView.native.tsx |
Metro platform override — unwraps the { nativeEvent: T } envelope from DirectEventHandler so callers receive RawPointerEvent directly |
RawPointerView.tsx |
Web stub — throws at runtime; provides TypeScript types for IDE navigation |
| Scenario | Result |
|---|---|
Multiple fingers on the same RawPointerView |
All tracked; each has a unique pointerId |
| Joystick held + button pressed | Both fire events simultaneously ✅ |
| Two joysticks held simultaneously | Both active simultaneously ✅ |
| Stationary finger while another moves | No spurious onRawPointerMove for stationary finger ✅ |
| System interruption (incoming call, etc.) | onRawPointerCancel fires for all active pointers; clean up state |
Platform limits:
iOS — UIKit delivers up to 5 simultaneous touches (11 on some iPad models).
Android — Hardware-dependent, typically 5–10 simultaneous touch points.
Contributions are welcome! Please read CONTRIBUTING.md for the development workflow and pull request guidelines, and CODE_OF_CONDUCT.md before participating.
# Clone the repo
git clone https://github.com/lctuan-duck/react-native-raw-pointer.git
cd react-native-raw-pointer
# Install dependencies
yarn install
# Run the example app
yarn example android
# or
yarn example iosMIT © 2026 Le Cong Tuan <lctuan.dev@gmail.com>