From react-native-3d
Handle touch events and gestures in React Three Fiber native apps. Covers raycasting, object selection, drag interactions, and resolving conflicts with OrbitControls.
How this skill is triggered — by the user, by Claude, or both
Slash command
/react-native-3d:touch-gesture-3dThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill covers the complex topic of handling touch interactions in R3F native apps - one of the most common sources of bugs.
This skill covers the complex topic of handling touch interactions in R3F native apps - one of the most common sources of bugs.
Touch handling in R3F native is tricky because:
User Touch
│
▼
┌─────────────────────────────────────┐
│ React Native Gesture System │
│ (react-native-gesture-handler) │
└─────────────────┬───────────────────┘
│
┌─────────────┴─────────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────────┐
│ R3F Canvas │ │ Other RN Views │
│ Pointer │ │ │
│ Events │ │ │
└──────┬──────┘ └─────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Three.js Raycaster │
│ (determines which object was hit) │
└─────────────────┬───────────────────┘
│
┌─────────────┴─────────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────────┐
│ Object │ │ OrbitControls │
│ Handlers │ │ (if enabled) │
│ onPointer* │ │ │
└─────────────┘ └─────────────────┘
For simple tap-to-select without drag:
import { Canvas } from '@react-three/fiber/native';
import { ThreeEvent } from '@react-three/fiber';
function SelectableNode({
position,
isSelected,
onSelect
}: {
position: [number, number, number];
isSelected: boolean;
onSelect: () => void;
}) {
return (
<mesh
position={position}
onPointerDown={(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation(); // CRITICAL: Prevent event bubbling
onSelect();
}}
>
<sphereGeometry args={[0.5, 16, 16]} />
<meshStandardMaterial
color={isSelected ? '#ff6b6b' : '#4ecdc4'}
/>
</mesh>
);
}
e.stopPropagation() to prevent OrbitControls from also handling the eventonPointerDown not onClick for faster response on mobileThreeEvent type gives you access to intersection dataWhen user taps empty space to place a new node:
import { useThree } from '@react-three/fiber/native';
import { useRef } from 'react';
import * as THREE from 'three';
function PlacementPlane({ onPlace }: { onPlace: (point: THREE.Vector3) => void }) {
const meshRef = useRef<THREE.Mesh>(null);
return (
<mesh
ref={meshRef}
rotation={[-Math.PI / 2, 0, 0]} // Horizontal plane
onPointerDown={(e) => {
e.stopPropagation();
// e.point is the world-space intersection point
onPlace(e.point.clone());
}}
visible={false} // Invisible but still receives events
>
<planeGeometry args={[100, 100]} />
<meshBasicMaterial transparent opacity={0} />
</mesh>
);
}
This is where it gets complex. You need to:
import { useThree } from '@react-three/fiber/native';
import { useRef, useState } from 'react';
import * as THREE from 'three';
function DraggableNode({
initialPosition,
onPositionChange
}: {
initialPosition: THREE.Vector3;
onPositionChange: (pos: THREE.Vector3) => void;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const [isDragging, setIsDragging] = useState(false);
const { camera, raycaster, pointer } = useThree();
// Plane for drag projection
const dragPlane = useRef(new THREE.Plane(new THREE.Vector3(0, 0, 1), 0));
const intersection = useRef(new THREE.Vector3());
const offset = useRef(new THREE.Vector3());
const handlePointerDown = (e: ThreeEvent<PointerEvent>) => {
e.stopPropagation();
setIsDragging(true);
// Calculate offset from object center to click point
if (meshRef.current) {
offset.current.copy(e.point).sub(meshRef.current.position);
// Orient drag plane to face camera
dragPlane.current.setFromNormalAndCoplanarPoint(
camera.getWorldDirection(new THREE.Vector3()),
e.point
);
}
// Capture pointer for tracking outside mesh bounds
(e.target as Element).setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: ThreeEvent<PointerEvent>) => {
if (!isDragging || !meshRef.current) return;
e.stopPropagation();
// Cast ray from camera through pointer
raycaster.setFromCamera(pointer, camera);
// Find intersection with drag plane
if (raycaster.ray.intersectPlane(dragPlane.current, intersection.current)) {
const newPos = intersection.current.sub(offset.current);
meshRef.current.position.copy(newPos);
onPositionChange(newPos.clone());
}
};
const handlePointerUp = (e: ThreeEvent<PointerEvent>) => {
setIsDragging(false);
(e.target as Element).releasePointerCapture(e.pointerId);
};
return (
<mesh
ref={meshRef}
position={initialPosition}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<sphereGeometry args={[0.5, 16, 16]} />
<meshStandardMaterial color={isDragging ? '#ff0000' : '#00ff00'} />
</mesh>
);
}
The biggest pain point: OrbitControls captures all touch events, blocking object interaction.
import { OrbitControls } from '@react-three/drei/native';
import { useRef } from 'react';
import { OrbitControls as OrbitControlsImpl } from 'three-stdlib';
function Scene() {
const controlsRef = useRef<OrbitControlsImpl>(null);
const [interacting, setInteracting] = useState(false);
return (
<>
<OrbitControls
ref={controlsRef}
enabled={!interacting} // Disable when interacting with objects
/>
<SelectableNode
onInteractionStart={() => setInteracting(true)}
onInteractionEnd={() => setInteracting(false)}
/>
</>
);
}
Replace OrbitControls with gesture-handler-based controls for finer control:
import { useThree, useFrame } from '@react-three/fiber/native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle } from 'react-native-reanimated';
import { View } from 'react-native';
function GestureControlledCanvas({ children }) {
const rotation = useSharedValue({ x: 0, y: 0 });
const scale = useSharedValue(1);
const rotateGesture = Gesture.Pan()
.onUpdate((e) => {
rotation.value = {
x: rotation.value.x + e.velocityY * 0.0001,
y: rotation.value.y + e.velocityX * 0.0001,
};
});
const pinchGesture = Gesture.Pinch()
.onUpdate((e) => {
scale.value = Math.max(0.5, Math.min(3, e.scale));
});
const composed = Gesture.Simultaneous(rotateGesture, pinchGesture);
return (
<GestureDetector gesture={composed}>
<View style={{ flex: 1 }}>
<Canvas>
<CameraController rotation={rotation} scale={scale} />
{children}
</Canvas>
</View>
</GestureDetector>
);
}
function CameraController({ rotation, scale }) {
const { camera } = useThree();
useFrame(() => {
// Apply rotation/scale to camera
camera.position.x = Math.sin(rotation.value.y) * 10 * scale.value;
camera.position.z = Math.cos(rotation.value.y) * 10 * scale.value;
camera.position.y = rotation.value.x * 5;
camera.lookAt(0, 0, 0);
});
return null;
}
Use different gesture areas for orbit vs object interaction:
function SplitControlCanvas() {
return (
<View style={{ flex: 1 }}>
{/* 3D Canvas - object interaction only */}
<Canvas style={{ flex: 1 }}>
<Scene />
{/* No OrbitControls here */}
</Canvas>
{/* Overlay for camera controls */}
<View
style={{ position: 'absolute', bottom: 0, height: 100, width: '100%' }}
pointerEvents="box-only"
>
{/* Gesture handlers for orbit here */}
</View>
</View>
);
}
Lines and custom geometries need explicit raycasting:
import * as THREE from 'three';
function SelectableLine({ points, onSelect }) {
const lineRef = useRef<THREE.Line>(null);
// Lines need a custom raycast threshold
useEffect(() => {
if (lineRef.current) {
// Set threshold for line selection (in world units)
const material = lineRef.current.material as THREE.LineMaterial;
lineRef.current.computeLineDistances(); // Required for raycasting
}
}, []);
return (
<line
ref={lineRef}
onPointerDown={(e) => {
e.stopPropagation();
onSelect();
}}
>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={points.length / 3}
array={new Float32Array(points)}
itemSize={3}
/>
</bufferGeometry>
<lineBasicMaterial color="#ffffff" linewidth={2} />
</line>
);
}
function DebugTouchPoint() {
const [lastTouch, setLastTouch] = useState<THREE.Vector3 | null>(null);
return (
<>
<mesh
onPointerMove={(e) => setLastTouch(e.point.clone())}
>
<planeGeometry args={[100, 100]} />
<meshBasicMaterial visible={false} />
</mesh>
{lastTouch && (
<mesh position={lastTouch}>
<sphereGeometry args={[0.1]} />
<meshBasicMaterial color="red" />
</mesh>
)}
</>
);
}
function DebugEventFlow({ children }) {
return (
<group
onPointerDown={(e) => console.log('Group pointerdown', e.object.name)}
onPointerUp={(e) => console.log('Group pointerup', e.object.name)}
onPointerMissed={() => console.log('Pointer missed - hit background')}
>
{children}
</group>
);
}
<Canvas
onPointerDown={() => console.log('Canvas received pointer down')}
onPointerMissed={() => console.log('Canvas pointer missed')}
>
| Mistake | Fix |
|---|---|
Not calling e.stopPropagation() | Always stop propagation for handled events |
Using onClick instead of onPointerDown | onPointerDown is more reliable on mobile |
| OrbitControls with default settings | Set enabled={false} when interacting with objects |
| Invisible objects not receiving events | They need a material, even if transparent |
| Lines not selectable | Call computeLineDistances() and set threshold |
| Drag not working outside object bounds | Use setPointerCapture/releasePointerCapture |
| Behavior | iOS | Android |
|---|---|---|
| Multi-touch | Works | Works |
| Pointer capture | Supported | Supported |
| Event timing | Slightly delayed | Immediate |
| Simulator testing | Unreliable | More reliable |
npx claudepluginhub smartwatermelon/smartwatermelon-marketplace --plugin react-native-3dImplements Three.js interactions: raycasting for object selection and picking, OrbitControls for camera, mouse/touch input handling. Use for clickable 3D scenes and user controls.
Build declarative 3D scenes with React Three Fiber in React apps using JSX components for Three.js objects. For interactive configurators, games, portfolios, and data viz.
Builds Three.js and React Three Fiber (R3F) 3D scenes: setup, animation, GLTF/GLB loading, physics with @react-three/rapier, WebGPU, Drei helpers, and performance optimization.