Three.js Interactivity & UI — Complete Guide to Raycasting, Drag-and-Drop, Labels & Click Detection
A static 3D scene is a demo. An interactive 3D scene is an experience. Three.js provides the tools to let users click, drag, hover, and engage with objects — transforming a viewer into a participant.
What You’ll Learn
By the end of this tutorial, you’ll implement raycasting for click and hover detection, use DragControls for drag-and-drop, add TransformControls for translate/rotate/scale gizmos, create CSS2D labels that float above objects, build HTML info panels, and construct a complete interactive 3D scene with all features combined.
Why Interactivity & UI Matter
Users expect to interact with 3D content — not just watch it. Click to select, hover to highlight, drag to reposition. In Durga Antivirus Pro’s 3D threat dashboard, analysts click on network nodes to inspect threat details, hover to see tooltips, and drag nodes to reorganize the threat landscape view. Without interactivity, the dashboard is a pretty picture; with it, it’s a powerful analysis tool.
flowchart LR
A[User Input] --> B[Raycaster]
B --> C{Interaction Type}
C --> D[Click → Select / Info Panel]
C --> E[Hover → Highlight / Tooltip]
C --> F[Drag → DragControls]
C --> G[Transform → Translate/Rotate/Scale]
D --> H[UI Output]
E --> H
F --> H
H --> I[CSS2DRenderer Labels]
H --> J[HTML Overlays]
I --> K[Interactive Scene Project]
J --> K
style B fill:#44aa88,color:#fff,stroke:none
The Raycaster — How Three.js Knows What You Clicked
The Raycaster is the foundation of all Three.js interaction. It works like this: when you click on the screen, the raycaster projects an invisible ray from the camera through the mouse position into the 3D scene. Any object the ray hits is “intersected.”
var raycaster = new THREE.Raycaster();
var mouse = new THREE.Vector2();
function onPointerMove(event) {
// Convert screen coordinates to Normalized Device Coordinates (-1 to +1)
// NDC maps the screen to a square where:
// x: -1 = left edge, +1 = right edge
// y: -1 = bottom edge, +1 = top edge
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
function cast() {
raycaster.setFromCamera(mouse, camera); // Cast ray through mouse position
var intersects = raycaster.intersectObjects(scene.children, true);
// intersects is sorted by distance — closest first
// Each hit contains: .object, .point, .distance, .face, .uv
if (intersects.length > 0) {
console.log("Hit:", intersects[0].object.name);
}
}Why normalize coordinates? Screen coordinates go from (0, 0) to (width, height). Three.js works in NDC (-1 to +1). The conversion (clientX / width) * 2 - 1 maps the entire screen range to this standardized space.
Mouse Picking (Click to Select)
var raycaster = new THREE.Raycaster();
var pointer = new THREE.Vector2();
renderer.domElement.addEventListener("click", function(event) {
// 1. Convert mouse position to NDC
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
// 2. Cast ray from camera through mouse position
raycaster.setFromCamera(pointer, camera);
// 3. Check against your objects (false = non-recursive)
var hits = raycaster.intersectObjects(clickableObjects, false);
// 4. Handle the first hit
if (hits.length > 0) {
var selected = hits[0].object;
selected.material.emissive.setHex(0x444444); // Glow effect
console.log("Selected:", selected.name || selected.uuid);
}
});Managing Selection State
Always keep a reference to the currently selected object so you can deselect it:
var currentSelection = null;
function pickObject(event) {
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
var hits = raycaster.intersectObjects(clickableObjects, false);
// Deselect previous
if (currentSelection) {
currentSelection.material.emissive.setHex(0x000000);
}
// Select new
if (hits.length > 0) {
currentSelection = hits[0].object;
currentSelection.material.emissive.setHex(0x444444);
} else {
currentSelection = null;
}
}Hover Effects
Use pointermove (not mousemove) for better touch and pen support:
var hoveredObject = null;
renderer.domElement.addEventListener("pointermove", function(event) {
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
var hits = raycaster.intersectObjects(clickableObjects, false);
// Un-hover previous
if (hoveredObject && hoveredObject !== hits[0]?.object) {
hoveredObject.material.color.setHex(hoveredObject.userData.originalColor);
hoveredObject = null;
}
// Hover new
if (hits.length > 0) {
hoveredObject = hits[0].object;
if (!hoveredObject.userData.originalColor) {
hoveredObject.userData.originalColor = hoveredObject.material.color.getHex();
}
hoveredObject.material.color.setHex(0xffaa00); // Highlight color
renderer.domElement.style.cursor = "pointer";
} else {
renderer.domElement.style.cursor = "default";
}
});DragControls — Drag to Move
DragControls makes objects draggable with zero manual raycasting:
import { DragControls } from "three/addons/controls/DragControls.js";
var draggableObjects = [cube, sphere, torus];
var controls = new DragControls(draggableObjects, camera, renderer.domElement);
controls.addEventListener("dragstart", function(event) {
event.object.material.emissive.setHex(0x333333);
});
controls.addEventListener("drag", function(event) {
// Update label positions, snap to grid, etc.
});
controls.addEventListener("dragend", function(event) {
event.object.material.emissive.setHex(0x000000);
});Working with OrbitControls: When dragging, disable OrbitControls to prevent conflicts:
dragControls.addEventListener("dragstart", function() {
orbitControls.enabled = false;
});
dragControls.addEventListener("dragend", function() {
orbitControls.enabled = true;
});CSS2DRenderer — Labels That Float Above Objects
HTML labels that always face the camera — perfect for tooltips and names:
import { CSS2DRenderer, CSS2DObject } from "three/addons/renderers/CSS2DRenderer.js";
// Create the label renderer (layered on top of WebGL)
var labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.domElement.style.position = "absolute";
labelRenderer.domElement.style.top = "0px";
labelRenderer.domElement.style.pointerEvents = "none"; // Let clicks pass through
document.body.appendChild(labelRenderer.domElement);
// Create a label element
var div = document.createElement("div");
div.textContent = "Cube";
div.style.color = "white";
div.style.fontFamily = "sans-serif";
div.style.fontSize = "14px";
div.style.textShadow = "0 1px 3px rgba(0,0,0,0.8)";
div.style.background = "rgba(0,0,0,0.5)";
div.style.padding = "4px 10px";
div.style.borderRadius = "4px";
var label = new CSS2DObject(div);
label.position.set(0, 1.5, 0); // Above the cube
scene.add(label);
// Render both in the animation loop
function animate() {
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
requestAnimationFrame(animate);
}Why CSS2DRenderer instead of CSS3DRenderer? CSS2DRenderer keeps labels screen-facing (like HUD elements). CSS3DRenderer puts HTML inside the 3D scene with perspective — more powerful but expensive.
HTML Overlays
Simple info panels as HTML positioned over the canvas:
<div id="info-panel" style="position:absolute;bottom:30px;left:50%;transform:translateX(-50%);
background:rgba(10,10,20,0.85);color:white;padding:16px 24px;border-radius:10px;
backdrop-filter:blur(6px);display:none;z-index:10;">
<h3 id="panel-title" style="margin:0 0 4px;"></h3>
<p id="panel-body" style="margin:0;font-size:13px;opacity:0.75;"></p>
</div>renderer.domElement.addEventListener("click", function(event) {
if (event.target !== renderer.domElement) return; // Ignore UI clicks
// ... raycast ...
if (hits.length > 0) {
panel.style.display = "block";
panelTitle.textContent = hits[0].object.name || "Unnamed";
panelBody.textContent = "Position: " + hits[0].object.position.toArray()
.map(v => v.toFixed(2)).join(", ");
} else {
panel.style.display = "none";
}
});Common Mistakes
1. Forgetting to update the raycaster
The raycaster uses the mouse vector from the last time you set it. If you move the mouse but don’t call setFromCamera, it’s still using stale coordinates.
2. Clicking through UI elements
When clicking a button over the canvas, the click event also fires on the canvas. Check event.target !== renderer.domElement to ignore UI clicks.
3. Not using recursive intersection for groups
raycaster.intersectObjects(array, false) only checks direct children. Use true for recursive checks into nested groups.
4. Overusing CSS3DRenderer
CSS3D objects are individual DOM elements. Keep the count low (under a few hundred) to avoid layout thrashing.
5. Not handling DragControls + OrbitControls conflicts
Dragging while OrbitControls is active creates a fight for camera control. Disable OrbitControls during drag.
6. Labels with pointer-events blocking interaction
CSS2D labels positioned over objects will intercept click events. Set pointerEvents: "none" on labels.
7. Not resetting material color after hover
If you change material.color on hover but don’t restore it when unhovering, objects stay highlighted forever.
Practice Questions
Q1: How does the Raycaster convert a 2D click into a 3D intersection? A: It projects a ray from the camera’s position through the mouse position (converted to NDC). The ray is tested against the bounding boxes/triangles of scene objects.
Q2: What’s the difference between intersectObjects with true vs false for the recursive parameter?
A: false only checks direct children of the array. true traverses into nested groups. Use true for loaded models that have hierarchical structure.
Q3: How do you prevent OrbitControls from interfering with DragControls?
A: Disable OrbitControls during drag: dragControls.addEventListener('dragstart', () => orbitControls.enabled = false) and re-enable on dragend.
Q4: Why should CSS2D labels have pointer-events: none?
A: Otherwise the label’s DOM element intercepts mouse events, preventing clicks from reaching the WebGL canvas and objects beneath.
Q5: What is the performance cost of raycasting every frame? A: Raycasting against a few dozen objects is fast. For thousands of objects, use a layer system or spatial index (octree) to limit checks.
Challenge: Build a scene with 10+ objects, each with unique hover colors and click behaviors. Clicking an object should show a custom HTML panel with its properties. Double-clicking should teleport the camera to face the object.
FAQ
Try It Yourself
Copy this complete HTML file. It includes hover highlighting, click info panels, drag-and-drop, and CSS2D labels — all working together.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Interactive 3D Scene — Three.js</title>
<style>
body { margin: 0; overflow: hidden; font-family: 'Segoe UI', sans-serif; }
#info-panel {
position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%);
background: rgba(10, 10, 20, 0.85); color: #fff;
padding: 16px 24px; border-radius: 10px; backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,0.12); display: none;
min-width: 220px; text-align: center; pointer-events: none; z-index: 10;
}
#info-panel h3 { margin: 0 0 4px 0; font-weight: 500; }
#info-panel p { margin: 0; font-size: 13px; opacity: 0.75; }
#instructions {
position: absolute; top: 20px; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.5); font-size: 14px;
background: rgba(0,0,0,0.4); padding: 8px 18px; border-radius: 20px;
pointer-events: none; z-index: 10;
}
</style>
</head>
<body>
<div id="instructions">Hover → highlight · Click → info · Drag → move</div>
<div id="info-panel">
<h3 id="panel-title">Object</h3>
<p id="panel-body">Position: 0.00, 0.00, 0.00</p>
</div>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { DragControls } from 'three/addons/controls/DragControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111122);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(5, 4, 8);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0px';
labelRenderer.domElement.style.pointerEvents = 'none';
document.body.appendChild(labelRenderer.domElement);
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
const ambient = new THREE.AmbientLight(0x404060);
scene.add(ambient);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight.position.set(5, 10, 7);
scene.add(dirLight);
const objects = [];
function createInteractive(geo, color, x, z, labelText) {
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.3, metalness: 0.4 });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, 0, z);
mesh.castShadow = true;
mesh.userData.label = labelText;
scene.add(mesh);
objects.push(mesh);
const div = document.createElement('div');
div.textContent = labelText;
div.style.cssText = 'color:#fff;font-size:12px;font-weight:600;background:rgba(0,0,0,0.55);padding:2px 10px;border-radius:12px;border:1px solid rgba(255,255,255,0.15);';
const label = new CSS2DObject(div);
label.position.set(x, 1.2, z);
scene.add(label);
return mesh;
}
createInteractive(new THREE.BoxGeometry(1, 1, 1), 0xff6633, -1.5, -1, 'Cube');
createInteractive(new THREE.SphereGeometry(0.6, 32, 32), 0x33bbff, 1.8, -0.5, 'Sphere');
createInteractive(new THREE.TorusGeometry(0.5, 0.2, 24, 48), 0xff44aa, -0.8, 1.8, 'Torus');
const dragControls = new DragControls(objects, camera, renderer.domElement);
dragControls.addEventListener('dragstart', () => { orbitControls.enabled = false; });
dragControls.addEventListener('dragend', () => { orbitControls.enabled = true; });
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hovered = null;
const defaultColors = new Map();
renderer.domElement.addEventListener('pointermove', (event) => {
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(objects, false);
if (hovered && hovered !== hits[0]?.object) {
hovered.material.color.setHex(defaultColors.get(hovered.uuid));
hovered = null;
renderer.domElement.style.cursor = 'default';
}
if (hits.length > 0) {
hovered = hits[0].object;
if (!defaultColors.has(hovered.uuid)) defaultColors.set(hovered.uuid, hovered.material.color.getHex());
hovered.material.color.setHex(0xffaa44);
renderer.domElement.style.cursor = 'pointer';
}
});
const panel = document.getElementById('info-panel');
const panelTitle = document.getElementById('panel-title');
const panelBody = document.getElementById('panel-body');
renderer.domElement.addEventListener('click', (event) => {
if (event.target !== renderer.domElement) return;
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(objects, false);
if (hits.length > 0) {
panel.style.display = 'block';
panelTitle.textContent = hits[0].object.userData.label || hits[0].object.type;
panelBody.textContent = `Position: ${hits[0].object.position.x.toFixed(2)}, ${hits[0].object.position.y.toFixed(2)}, ${hits[0].object.position.z.toFixed(2)}`;
} else { panel.style.display = 'none'; }
});
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.setSize(window.innerWidth, window.innerHeight);
});
function animate() {
requestAnimationFrame(animate);
orbitControls.update();
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
}
animate();
</script>
</body>
</html>What’s Next
| Topic | Description | Link |
|---|---|---|
| Particles | Interactive particle systems | https://tutorials.dodatech.com/frontend/libraries/threejs/threejs-particles/ |
| Post-Processing | Adding effects to interactive scenes | https://tutorials.dodatech.com/frontend/libraries/threejs/threejs-post-processing/ |
| Models & Loading | Raycasting on loaded 3D models | https://tutorials.dodatech.com/frontend/libraries/threejs/threejs-models-loading/ |
| Animation | Animating UI and object states | https://tutorials.dodatech.com/frontend/libraries/threejs/threejs-animation/ |
| Cameras & Controls | Control schemes for interactivity | https://tutorials.dodatech.com/frontend/libraries/threejs/threejs-cameras-controls/ |
| HTML | Creating overlay UI elements | HTML |
| CSS | Styling labels and overlays | CSS |
You now have a complete toolkit for making Three.js scenes interactive — from raycasting and click detection to draggable objects and floating labels. The same patterns power the interactive node graph in Durga Antivirus Pro, where security analysts can click, hover, and drag to investigate threats.
What’s Next
Congratulations on completing this Threejs Interactivity Ui tutorial! Here’s where to go from here:
- Practice daily — Consistency is more important than long study sessions
- Build a project — Apply what you learned by building something real
- Explore related topics — Check out other tutorials in the same category
- Join the community — Discuss with other learners and share your progress
Remember: every expert was once a beginner. Keep coding!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro