Navigate

Search topics across all sections

GitHub
Events & Interaction

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.

terminal
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.

InteractiveBox.tsxTSX
<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.

EventData.tsxTSX
<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.

CursorHint.tsxTSX
<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.

1
Binding onPointerMove on many objects
Raycasting against 100+ objects on every mouse movement
Don't do this
Scene.tsxTSX
// Each mesh triggers raycasting on pointer move
{Array.from({ length: 100 }, (_, i) => (
  <mesh key={i}
    onPointerMove={(e) => console.log(i)}>
    <boxGeometry />
    <meshStandardMaterial />
  </mesh>
))}
R3F raycasts against every object that has event handlers. onPointerMove fires on every mouse movement, and raycasting 100+ objects each time is expensive. Use event delegation: attach one handler to a parent group and use event.object to identify which child was hit.
2
Forgetting stopPropagation
Parent handlers fire unexpectedly when clicking children
Don't do this
Scene.tsxTSX
<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>
R3F events bubble up through the scene graph, just like DOM events. Clicking a child mesh fires its handler, then propagates to all ancestor groups with handlers. Call stopPropagation() on the child to prevent the parent from also firing.
3
Not restoring cursor on pointer out
Cursor stays as pointer after moving away from the object
Don't do this
Button.tsxTSX
<mesh
  onPointerOver={() => {
    document.body.style.cursor = 'pointer'
  }}
  // Forgot onPointerOut! Cursor stuck forever.
>
Changing the CSS cursor in onPointerOver without resetting it in onPointerOut leaves the cursor stuck. Always pair them. Or use drei's useCursor hook which handles this automatically based on a boolean state.

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.