Interactions

threejs raycaster

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)
  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:

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:

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

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:

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:

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:

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.

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:

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.

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.

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.

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:

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:

// 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):

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

GOOD — only check what's interactive:

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:

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:

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:

// 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 distanceintersects[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 eventspointermove/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.