Three.js Raycaster: Click and Hover Detection

THREE.Raycaster finds which 3D objects a ray intersects. Cast a ray from the camera through the mouse's normalized device coordinates with raycaster.setFromCamera(mouse, camera), then call raycaster.intersectObjects(objects) to get a sorted list of hits, which is how Three.js implements clicking, hovering and dragging 3D objects with a 2D mouse.

Last updated . Verified against three.js r181.

Read the FAQJump to code

The standard click/hover pattern

Raycasting for mouse interaction always follows the same three steps:

  1. Convert mouse pixel coordinates to normalized device coordinates (NDC). This means a range of -1 to +1 on both axes, with (0,0) at the screen center. It's not the same as the raw clientX/clientY pixel values; forgetting to normalize is the most common raycaster bug.
  2. Update the raycaster with raycaster.setFromCamera(mouseNDC, camera), which computes the ray from the camera through that screen point.
  3. Intersect with raycaster.intersectObjects(objects, recursive). The result is an array sorted by distance, nearest first, so intersects[0] is what the user actually clicked or hovered, even if other objects are behind it along the same ray.

Each intersection result includes object, point (the world-space hit position), distance, face and uv, enough to place a decal, read which triangle was hit, or sample a texture at the click point.

Hover highlighting vs click selection

Hover detection runs the same raycast on pointermove, typically every frame or throttled to the mouse-move event, and swaps the previously-hovered object's material or emissive color back before highlighting the new one. Tracking the "currently hovered" object explicitly avoids flicker when the ray briefly hits nothing between two overlapping objects.

Click selection is the same raycast run once on pointerdown/click. A common mistake: forgetting to check intersects.length > 0 before reading intersects[0], which throws on an empty click.

Keeping raycasting fast in large scenes

Raycasting against every mesh in a large scene every frame gets expensive. The standard mitigations:

  • Throttle hover raycasts. You rarely need to raycast on every single pointermove event; every other frame or a small debounce is usually imperceptible.
  • Restrict the candidate list. Pass only the objects that can actually be interacted with to intersectObjects(), not the whole scene graph. Group interactive objects under one parent and raycast against its children.
  • Use bounding volumes for the first pass. For very large scenes, a spatial index (octree, or even a simple bounding-sphere pre-check) can cull most objects before the expensive triangle-level raycast runs.
  • Set recursive correctly. intersectObjects(objects, false) skips descending into children when you don't need to, which is cheaper when your interactive objects are flat, non-nested meshes.

Code

Click and hover detection (r181)
1import * as THREE from 'three'
2
3const raycaster = new THREE.Raycaster()
4const mouse = new THREE.Vector2()
5let hovered = null
6
7function onPointerMove(event) {
8 mouse.x = (event.clientX / window.innerWidth) * 2 - 1
9 mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
10
11 raycaster.setFromCamera(mouse, camera)
12 const intersects = raycaster.intersectObjects(interactiveGroup.children, false)
13
14 if (hovered && (!intersects.length || intersects[0].object !== hovered)) {
15 hovered.material.emissive.setHex(0)
16 hovered = null
17 }
18 if (intersects.length && intersects[0].object !== hovered) {
19 hovered = intersects[0].object
20 hovered.material.emissive.setHex(0x333333)
21 }
22}
23
24window.addEventListener('pointermove', onPointerMove)
25window.addEventListener('click', () => {
26 if (hovered) console.log('Clicked:', hovered.name)
27})
Raycasting is renderer-agnostic
1// THREE.Raycaster operates on the scene graph and geometry. It works
2// identically whether your materials are MeshStandardMaterial or TSL node
3// materials, and whether you're using WebGLRenderer or WebGPURenderer.
4// The only TSL-relevant detail: reading material color back from a node
5// material requires the node's cached value, not a plain .color property,
6// if you built the color procedurally rather than from a Color instance.
7raycaster.setFromCamera(mouse, camera)
8const intersects = raycaster.intersectObjects(scene.children, true)

Learn this properly

Learn Practical TSL

Your First Node Material

Build the material you'll be selecting with a raycaster in this first lesson.

Start the lesson (6 minutes)

Frequently asked questions

Why does my Three.js raycaster not detect clicks correctly?

The most common cause is using raw pixel coordinates instead of normalized device coordinates (-1 to +1) when calling setFromCamera. Also check you're passing the right object list to intersectObjects and reading intersects[0], not the whole array, for the nearest hit.

How do I raycast against only certain objects in Three.js?

Pass an explicit array to raycaster.intersectObjects() rather than the whole scene. Group interactive objects under one parent object and raycast against interactiveGroup.children.

Is raycasting expensive in Three.js?

Against a small number of objects, no. It's fast enough to run every frame for hover effects. Against thousands of high-poly meshes, it can become a bottleneck; throttle the raycast frequency and restrict the candidate object list to mitigate it.

Keep reading