---
date: 2026-7-6
---

<!-- markdownlint-disable MD001 -->

# Interactions

![threejs raycaster](https://imgs.search.brave.com/RrBz5-fKGNo6sj-dZFPrdwowzD0rT9elhDAY8SkhLqU/rs:fit:860:0:0:0/g:ce/aHR0cHM6Ly90aHJl/ZWpzLWpvdXJuZXku/Y29tL2Fzc2V0cy9s/ZXNzb25zLzE5LzAw/MC5wbmc)

You've built a scene. You've got objects spinning, lights shining, maybe even a model loaded. But right now it's a **movie** — you watch, but you can't touch.

What if you could **click** a cube and make it turn red? **Hover** over a button and see it glow? **Drag** a shape across the screen?

That's interactions — and in Three.js, almost all of them start with the same tool: the **Raycaster**.

## what's a raycaster?

Imagine shooting an invisible laser from your mouse cursor straight into the 3D scene. That laser travels forward, and if it hits anything, Three.js tells you exactly what it hit and where.

That's all a `Raycaster` does. You point it in a direction, ask it what it intersects with, and get back a list of hits.

No magic. Just math — a line, some objects, and a distance check.

## setting up for interaction

Before you can click on anything, you need:

1. A **render loop** that's already running (covered in [Create a Scene](/threejs-blog/2))
2. A **camera** and **scene** set up
3. A list of objects the user can interact with

Let's start with a basic scene and a clickable cube:

```javascript
import * as THREE from 'three';

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000,
);
camera.position.z = 5;

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// A cube we'll click on
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x00aaff });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// Light so we can see it
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
scene.add(light);

// Render loop
function animate() {
    renderer.render(scene, camera);
    requestAnimationFrame(animate);
}
animate();
```

Now we have a blue cube. Let's make it clickable.

## click detection — the full pattern

Here's the standard recipe for detecting a click on a 3D object:

```javascript
// 1. Create the raycaster and a mouse vector
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();

// 2. Listen for click events
renderer.domElement.addEventListener('click', (event) => {
    // 3. Convert mouse position to normalized device coordinates (-1 to 1)
    pointer.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1;
    pointer.y = -(event.clientY / renderer.domElement.clientHeight) * 2 + 1;

    // 4. Point the raycaster from the camera through the mouse position
    raycaster.setFromCamera(pointer, camera);

    // 5. Check what objects the ray intersects
    const intersects = raycaster.intersectObject(cube);

    if (intersects.length > 0) {
        // We clicked the cube!
        cube.material.color.setHex(0xff4444); // turn red
    } else {
        // We clicked empty space
        cube.material.color.setHex(0x00aaff); // back to blue
    }
});
```

Let's break down what's happening in step 3, because it's the part that trips people up.

### normalized device coordinates

Mouse events give you pixel coordinates — `event.clientX` might be `450`, `event.clientY` might be `300`. But Three.js works in a range from `-1` to `1` for both X and Y.

```text
Screen coordinates (pixels)     →     Normalized (-1 to 1)

   (0, 0)          (800, 0)
       ┌──────────────┐                    (-1, 1)     (1, 1)
       │              │                        ┌──────────┐
       │    (450,     │                        │          │
       │     300)     │           →            │  (0, 0)  │
       │              │                        │          │
       └──────────────┘                        └──────────┘
   (0, 600)      (800, 600)               (-1, -1)    (1, -1)
```

The formula is always the same:

```javascript
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
```

The `-` in front of Y is because screens count Y from top to bottom, but Three.js counts Y from bottom to top.

> **☝️ tip:** Use `renderer.domElement.clientWidth/Height` instead of `window.innerWidth/Height` if your canvas doesn't fill the whole page.

### checking multiple objects

Clicking on one cube is fine. But what if you have 10 objects? Or 100? You pass an array to `intersectObjects`:

```javascript
const clickableObjects = [cube, sphere, torus, cone];

renderer.domElement.addEventListener('click', (event) => {
    pointer.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1;
    pointer.y = -(event.clientY / renderer.domElement.clientHeight) * 2 + 1;

    raycaster.setFromCamera(pointer, camera);

    const intersects = raycaster.intersectObjects(clickableObjects);

    if (intersects.length > 0) {
        // The first hit is the closest object to the camera
        const hit = intersects[0].object;
        hit.material.color.setHex(0xff4444);

        console.log('Hit:', hit.name, 'at distance:', intersects[0].distance);
    }
});
```

`intersects` is sorted by distance — `intersects[0]` is the closest thing you clicked.

---

## hover effects

Clicking is great. But hovering tells the user **something is clickable** before they click.

Same raycaster pattern, but on `pointermove` instead of `click`:

```javascript
let previousHit = null;

renderer.domElement.addEventListener('pointermove', (event) => {
    pointer.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1;
    pointer.y = -(event.clientY / renderer.domElement.clientHeight) * 2 + 1;

    raycaster.setFromCamera(pointer, camera);
    const intersects = raycaster.intersectObjects(clickableObjects);

    // Reset the previously hovered object
    if (previousHit) {
        previousHit.material.color.setHex(previousHit.userData.originalColor);
        previousHit = null;
    }

    if (intersects.length > 0) {
        const hit = intersects[0].object;
        hit.material.color.setHex(0x88ff88); // highlight green
        renderer.domElement.style.cursor = 'pointer';
        previousHit = hit;
    } else {
        renderer.domElement.style.cursor = 'default';
    }
});
```

> **⚠️ important:** Store the original color (`hit.userData.originalColor = 0x00aaff`) when you create the object, so you can restore it when the user hovers away. Otherwise you'll lose it.

### hover + click combined

The usual pattern is: **hover changes the cursor, click does the action**.

```javascript
renderer.domElement.addEventListener('pointermove', (event) => {
    /* ... raycaster check ... */
    // If hovering over clickable object:
    renderer.domElement.style.cursor = 'pointer';
    // Otherwise:
    renderer.domElement.style.cursor = 'default';
});

renderer.domElement.addEventListener('click', (event) => {
    /* ... raycaster check ... */
    // Do the thing — open a door, select a unit, change a color
});
```

---

## full example — clickable cubes

Here's everything put together — a row of cubes you can hover and click:

```javascript
import * as THREE from 'three';

// Scene setup
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000,
);
camera.position.set(0, 2, 6);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Lights
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(5, 10, 7);
scene.add(dirLight);

// Create clickable cubes
const colors = [0xff4444, 0x44ff44, 0x4444ff, 0xffff44, 0xff44ff];
const cubes = [];

colors.forEach((color, i) => {
    const geometry = new THREE.BoxGeometry(0.8, 0.8, 0.8);
    const material = new THREE.MeshStandardMaterial({ color });
    const cube = new THREE.Mesh(geometry, material);
    cube.position.x = (i - 2) * 1.5;
    cube.userData.originalColor = color; // save for hover reset
    scene.add(cube);
    cubes.push(cube);
});

// Raycaster setup
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let previousHover = null;

// Hover
renderer.domElement.addEventListener('pointermove', (event) => {
    pointer.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1;
    pointer.y = -(event.clientY / renderer.domElement.clientHeight) * 2 + 1;

    raycaster.setFromCamera(pointer, camera);
    const intersects = raycaster.intersectObjects(cubes);

    // Reset previous hover
    if (previousHover) {
        previousHover.material.color.setHex(
            previousHover.userData.originalColor,
        );
        previousHover = null;
    }

    if (intersects.length > 0) {
        const hit = intersects[0].object;
        hit.material.color.setHex(0xffffff); // white highlight
        renderer.domElement.style.cursor = 'pointer';
        previousHover = hit;
    } else {
        renderer.domElement.style.cursor = 'default';
    }
});

// Click
renderer.domElement.addEventListener('click', (event) => {
    pointer.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1;
    pointer.y = -(event.clientY / renderer.domElement.clientHeight) * 2 + 1;

    raycaster.setFromCamera(pointer, camera);
    const intersects = raycaster.intersectObjects(cubes);

    if (intersects.length > 0) {
        const hit = intersects[0].object;
        // Random color on click
        hit.material.color.setHex(Math.random() * 0xffffff);
        hit.userData.originalColor = hit.material.color.getHex();
    }
});

// Render loop
function animate() {
    renderer.render(scene, camera);
    requestAnimationFrame(animate);
}
animate();

// Handle resize
window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
});
```

---

## built-in controls — way easier than rolling your own

Before you build custom click-and-drag interactions, check if one of Three.js's built-in controls already does what you need.

### OrbitControls — rotate/pan/zoom

You've probably seen this everywhere. Lets the user drag to orbit around a scene, scroll to zoom, right-click to pan.

```javascript
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // smooth, inertial movement

// Then in your render loop:
function animate() {
    controls.update(); // required when damping is on
    renderer.render(scene, camera);
    requestAnimationFrame(animate);
}
```

**Best for:** Viewers, portfolios, 3D model previews — anywhere the user should freely look around.

### DragControls — click and drag objects

Lets the user pick up objects and move them around.

```javascript
import { DragControls } from 'three/examples/jsm/controls/DragControls.js';

const objects = [cube, sphere, torus];
const controls = new DragControls(objects, camera, renderer.domElement);

controls.addEventListener('dragstart', (event) => {
    event.object.material.color.setHex(0xffaa00);
});

controls.addEventListener('dragend', (event) => {
    event.object.material.color.setHex(event.object.userData.originalColor);
});
```

No raycaster needed — `DragControls` handles all the mouse math internally.

**Best for:** Drag-and-drop puzzles, 3D builders, rearrangeable scenes.

### PointerLockControls — first-person camera

Captures the mouse pointer and lets the user look around by moving the mouse — like an FPS game.

```javascript
import { PointerLockControls } from 'three/examples/jsm/controls/PointerLockControls.js';

const controls = new PointerLockControls(camera, renderer.domElement);

renderer.domElement.addEventListener('click', () => {
    controls.lock(); // captures the mouse
});

// Then use WASD keys for movement (you handle that part)
```

**Best for:** Games, walkthroughs, immersive 3D experiences.

---

## drag without DragControls (for when you need custom behavior)

If `DragControls` doesn't fit your use case (maybe you want drag on only one axis, or with snapping), you can build it yourself with the raycaster:

```javascript
let isDragging = false;
let selectedObject = null;
const dragPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const intersection = new THREE.Vector3();

renderer.domElement.addEventListener('pointerdown', (event) => {
    pointer.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1;
    pointer.y = -(event.clientY / renderer.domElement.clientHeight) * 2 + 1;

    raycaster.setFromCamera(pointer, camera);
    const intersects = raycaster.intersectObjects(draggableObjects);

    if (intersects.length > 0) {
        isDragging = true;
        selectedObject = intersects[0].object;
    }
});

renderer.domElement.addEventListener('pointermove', (event) => {
    if (!isDragging || !selectedObject) return;

    pointer.x = (event.clientX / renderer.domElement.clientWidth) * 2 - 1;
    pointer.y = -(event.clientY / renderer.domElement.clientHeight) * 2 + 1;

    raycaster.setFromCamera(pointer, camera);
    raycaster.ray.intersectPlane(dragPlane, intersection);

    if (intersection) {
        selectedObject.position.copy(intersection);
    }
});

renderer.domElement.addEventListener('pointerup', () => {
    isDragging = false;
    selectedObject = null;
});
```

This creates an invisible plane at `z = 0` and drags objects across it.

---

## mobile interactions

On mobile, there's no hover — just touch. The good news: `pointermove`, `pointerdown`, and `pointerup` work on both desktop and mobile, so your raycaster code usually works as-is.

But there are two things to watch for:

### 1. no hover on mobile

Don't rely on `pointermove` hover effects as the **only** way to discover something is clickable. Add visual indicators (glow, color, animation) that are always visible.

### 2. touch events fire differently

On mobile, `pointermove` only fires while you're actively touching the screen. So hover effects that depend on `pointermove` won't show unless the user is dragging their finger.

A practical approach:

```javascript
// Check if the device supports hover
const hasHover = window.matchMedia('(hover: hover)').matches;

if (hasHover) {
    // Only attach hover effects on desktop
    renderer.domElement.addEventListener('pointermove', handlePointerMove);
}

// Click/tap works everywhere
renderer.domElement.addEventListener('pointerdown', handlePointerDown);
```

---

## performance tips

### use a list of objects, not the whole scene

❌ **BAD** — checks everything in the scene (expensive):

```javascript
raycaster.intersectObjects(scene.children); // 🙅 slow with many objects
```

✅ **GOOD** — only check what's interactive:

```javascript
const clickableObjects = [door, switch, key]; // keep a separate list
raycaster.intersectObjects(clickableObjects);
```

### don't raycast every frame if you don't need to

❌ **BAD** — raycasting 60 times per second in the render loop when nothing is moving:

```javascript
function animate() {
    // 🙅 Running every frame even when mouse hasn't moved
    raycaster.setFromCamera(pointer, camera);
    raycaster.intersectObjects(clickableObjects);
    renderer.render(scene, camera);
}
```

✅ **GOOD** — only raycast on the events that need it:

```javascript
renderer.domElement.addEventListener('pointermove', handlePointerMove);
renderer.domElement.addEventListener('click', handleClick);
```

### clean up when you're done

If your scene is inside a Svelte/React component, clean up event listeners and references when the component unmounts:

```javascript
// Store references so you can remove them
function cleanup() {
    renderer.domElement.removeEventListener('pointermove', handlePointerMove);
    renderer.domElement.removeEventListener('click', handleClick);
    renderer.dispose();
}
```

---

## which approach should you use?

| You want to...                        | Use this                                |
| ------------------------------------- | --------------------------------------- |
| Click/hover on objects                | Raycaster + event listeners             |
| Rotate/zoom/pan around a scene        | `OrbitControls`                         |
| Drag objects around the screen        | `DragControls` or custom raycaster drag |
| First-person mouse look (like an FPS) | `PointerLockControls`                   |
| Support mobile touch                  | `pointermove/pointerdown` (works as-is) |
| Just detect what's under the cursor   | Raycaster + `intersectObjects()`        |

## a few tips to remember

- **The raycaster is cheap for small lists** — checking 10–20 objects per click is instant. Checking 10,000 is not. Keep your interactive list small.
- **Store original colors** in `userData` so you can revert hover effects cleanly.
- **`intersectObjects` returns results sorted by distance** — `intersects[0]` is always the closest.
- **Test on mobile** — hover doesn't exist on touch devices. Make sure click targets are big enough (don't make the user try to tap a tiny wireframe).
- **Use `pointer` events instead of `mouse` events** — `pointermove/pointerdown/pointerup` work on both mouse and touch. `mousedown/mouseup` do not.

## why this matters

Without interactions, your 3D scene is a screensaver — pretty, but passive. With a raycaster and a few event listeners, it becomes something the user can **touch**, **explore**, and **affect**.

Click a door to open it. Hover over a character to see their name. Drag a piece into place. That's the difference between watching a movie and playing a game.

And it all starts with an invisible laser beam.
