Navigate
Search topics across all sections
Pointer Events
A 3D scene you can only look at is like a painting -- beautiful but passive. Pointer events let your users reach into the scene and interact. Click a button, hover a card, drag a slider -- all with the same React event handler syntax you already know from regular HTML elements.
You attach onPointerMove to 200 individual meshes in your scene. It works, but every time the user moves their mouse, R3F raycasts against all 200 objects. Your frame rate drops from 60fps to 15fps on mobile, and users think your app is broken.
Performance warning: 200 meshes with onPointerMove handlers. Raycasting 200 objects per mouse movement (60x/sec). Total intersection tests: 12,000/sec. Frame time: 67ms (15 FPS). Consider event delegation on a parent group.
Real-world
Imagine your 3D scene is a giant touchscreen. Hover over a cube and it glows. Click a sphere and it bounces. Drag a shape and it follows your finger. That is what pointer events give you.
Just like a touchscreen, the system needs to figure out what you are touching. Behind the scenes, R3F shoots an invisible ray from your mouse into the 3D world to detect which object is under the cursor. Then it fires the matching event handler -- just like a button click in regular HTML.
How Pointer Events Work
User clicks
Browser fires a pointer event
R3F raycasts
Invisible ray from camera through mouse
Find intersections
Which objects does the ray hit?
Sort by distance
Nearest object fires first
Event fires
onClick runs on the hit mesh
See It In Action
Click and hover over the objects below. Each one responds to pointer events -- changing color, scale, or position in response to your interactions.
Building It Step by Step
Step 1 -- Add click and hover handlers
Attach event handlers directly to mesh components. It is the same syntax you use for HTML buttons -- onClick, onPointerOver, onPointerOut.
<mesh
onClick={(e) => {
e.stopPropagation()
setClicked(!clicked)
}}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<boxGeometry />
<meshStandardMaterial color={hovered ? 'hotpink' : 'orange'} />
</mesh>Notice stopPropagation() on the click handler. Without it, the click would bubble up to parent groups, just like in the DOM.
Step 2 -- Read the event object
The event gives you rich 3D data: exactly where the click landed in world space, which face was hit, the distance from the camera, and the UV coordinates at the hit point.
<mesh onClick={(e) => {
e.stopPropagation()
console.log(e.point) // Vector3 world position
console.log(e.object) // the mesh that was hit
console.log(e.distance) // distance from camera
console.log(e.face) // hit face + normal
console.log(e.uv) // UV at hit point
}}>
<sphereGeometry />
<meshStandardMaterial />
</mesh>event.point is especially useful -- it tells you the exact 3D coordinate where the user clicked, so you can spawn particles, place markers, or trigger effects at that spot.
Step 3 -- Change the cursor for hover feedback
Give users visual feedback by changing the CSS cursor when they hover over interactive objects. Always pair the over and out handlers.
<mesh
onPointerOver={() => {
document.body.style.cursor = 'pointer'
}}
onPointerOut={() => {
document.body.style.cursor = 'auto'
}}
>
<boxGeometry />
<meshStandardMaterial />
</mesh>For a cleaner approach, drei provides a useCursor(hovered) hook that handles cursor management automatically based on a boolean.
What you just learned
Pointer events work just like React HTML events -- onClick, onPointerOver, onPointerOut
R3F uses raycasting behind the scenes to detect which 3D object is under the cursor
Events bubble up through the scene graph -- use stopPropagation() to prevent it
The event object gives you 3D data: hit position, face, distance, UV coordinates
Always pair onPointerOver cursor changes with onPointerOut resets
Question
What about drag events? R3F does not have built-in drag handlers, but drei provides a <DragControls> component and a useDrag hook. Under the hood, they combine onPointerDown, onPointerMove, and onPointerUp to track drag gestures and update object positions.
Think about it...
You have a group containing a background plane and a floating button mesh. Both have onClick handlers. The user clicks the button. What happens?
Try These Challenges
Put what you learned into practice. Try each challenge in the demo above using the Leva controls, then check the solution.
Try This!
Beginner
Set hoverScale to 2 -- giant on hover!
Try This!
Beginner
Max out bounceHeight -- objects fly on click!
Try This!
Beginner
Set rotationSpeed to 0 -- still objects.
These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.
// Each mesh triggers raycasting on pointer move
{Array.from({ length: 100 }, (_, i) => (
<mesh key={i}
onPointerMove={(e) => console.log(i)}>
<boxGeometry />
<meshStandardMaterial />
</mesh>
))}<group onClick={() => console.log('Background')}>
{/* Clicking this also fires the parent! */}
<mesh onClick={() => console.log('Button')}>
<boxGeometry args={[0.5, 0.5, 0.1]} />
</mesh>
</group><mesh
onPointerOver={() => {
document.body.style.cursor = 'pointer'
}}
// Forgot onPointerOut! Cursor stuck forever.
>Best Practices
Always call stopPropagation
Prevent parent handlers from firing unexpectedly. This is the single most common source of event bugs in R3F.
Disable raycasting on decorative meshes
Use raycast={() => null} on non-interactive meshes to skip intersection testing entirely.
Use event delegation
Attach one handler to a parent group and use event.object to identify the child. Much faster than individual handlers on many objects.
Always pair cursor changes
Every onPointerOver cursor change needs a matching onPointerOut reset. Or use drei's useCursor hook.