Interactions
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:
- A render loop that's already running (covered in Create a Scene)
- A camera and scene set up
- 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/Heightinstead ofwindow.innerWidth/Heightif 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
userDataso you can revert hover effects cleanly. intersectObjectsreturns 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
pointerevents instead ofmouseevents —pointermove/pointerdown/pointerupwork on both mouse and touch.mousedown/mouseupdo 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.
