Skip to content
Three.js Interactivity & UI — Complete Guide to Raycasting, Drag-and-Drop, Labels & Click Detection

Three.js Interactivity & UI — Complete Guide to Raycasting, Drag-and-Drop, Labels & Click Detection

DodaTech Updated Jun 6, 2026 11 min read

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

How do I make objects clickable but not draggable?
Use separate arrays for raycasting and DragControls. An object can be in both arrays for dual behavior.
Why do my labels not show up on mobile?
Ensure the CSS2DRenderer DOM element has a size set and the viewport meta tag is configured. Check that pointer-events isn’t blocking touches.
Can I use OrbitControls and DragControls together?
Yes. Disable OrbitControls during drag by listening to ‘dragstart’ and ‘dragend’ events.
What is the performance cost of the Raycaster?
Fast for a few dozen objects per frame. For large scenes, use a layer system or Octree spatial index.
How do I detect clicks on transparent parts of a texture?
Raycasting uses geometry, not texture alpha. For alpha-based picking, use an off-screen render with a unique color per object.
Why does DragControls feel jittery?
Ensure the renderer has no CSS transforms/scaling. DragControls expects the same camera as the renderer.

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

TopicDescriptionLink
ParticlesInteractive particle systemshttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-particles/
Post-ProcessingAdding effects to interactive sceneshttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-post-processing/
Models & LoadingRaycasting on loaded 3D modelshttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-models-loading/
AnimationAnimating UI and object stateshttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-animation/
Cameras & ControlsControl schemes for interactivityhttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-cameras-controls/
HTMLCreating overlay UI elementsHTML
CSSStyling labels and overlaysCSS

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