Back to UI/UX Design

threejs-interaction

Three.js3D graphicsWeb3DraycastingOrbitControlsuser inputinteractivejavascript
2.4k🕒 2026-01-19Source ↗

Install this skill

npx skills add cloudai-x/threejs-skills

Works across Claude Code, Cursor, Codex, Copilot & Antigravity

The threejs-interaction skill facilitates user engagement within 3D environments by mapping 2D inputs to 3D coordinate spaces. It provides structured methods for object selection, camera navigation, and event handling. By implementing Raycaster routines, developers can pinpoint specific meshes in a scene based on mouse or touch coordinates. The skill also incorporates standard control schemes such as OrbitControls and FlyControls, allowing users to move, zoom, and rotate perspectives through calculated updates within the render loop. This toolkit simplifies the conversion of screen-space vectors into world-space vectors, essential for creating interactive dashboards, architectural walkthroughs, or web-based visualizations. It focuses on the mechanics of picking and navigating, ensuring that event listeners effectively communicate with Three.js object trees for precise interaction management in browser-based graphical experiences.

When to Use This Skill

  • Building clickable UI elements inside a 3D scene
  • Developing orbital viewing experiences for product showcases
  • Implementing first-person exploration of virtual spaces
  • Creating interactive hit-testing for complex meshes

How to Invoke This Skill

Example prompts that trigger this skill in Claude Code, Cursor, or Antigravity:

  • How do I click on objects in my Three.js scene?
  • Set up camera rotation around a target point
  • Convert screen coordinates to 3D world position
  • Implement raycasting for mouse selection
  • Limit the distance users can zoom in my scene

Pro Tips

  • 💡Optimize raycasting performance by only checking intersections with a subset of relevant objects (e.g., using layers or specific groups) instead of all `scene.children` in complex scenes.
  • 💡For mobile experiences, ensure both mouse events and touch events (touchstart, touchmove, touchend) are handled correctly, mapping them to `THREE.Vector2` for consistent raycasting.
  • 💡Combine different control types (e.g., OrbitControls with custom object manipulation logic) by carefully managing their enable/disable states or using event listeners to prevent conflicts.

What this skill does

  • Screen-to-world coordinate mapping via Raycasting
  • Configuration of orbit and fly-based camera movement schemes
  • Filtering of interactable objects using Three.js layers
  • Optimization of intersection checks for mouse movement and touch events
  • Constraint enforcement on camera rotation and zoom limits

When not to use it

  • Handling pure 2D HTML/CSS interface logic without 3D integration
  • Physics-based interaction requiring rigid-body collision response
  • High-performance real-time applications requiring GPGPU-based picking

Example workflow

  1. Initialize a Raycaster instance and a Vector2 for mouse state.
  2. Add an event listener to the canvas to track mouse position updates.
  3. Normalize mouse coordinates relative to the full window size.
  4. Perform intersection checks against scene objects in the render loop.
  5. Handle the callback logic for the intersected mesh object.

Prerequisites

  • An existing Three.js scene, camera, and renderer
  • Basic knowledge of the browser DOM and event listeners

Pitfalls & limitations

  • !Raycasting on every mouse move frame can significantly impact performance without throttling.
  • !Failing to call controls.update() in the animation loop breaks damping and auto-rotate behavior.
  • !Intersection results depend on the order of objects, which can cause issues with nested transparent geometry.

FAQ

Why is my raycaster returning empty results?
Ensure the objects you are checking are within the camera frustum and that the raycaster's near/far planes are correctly set to cover the objects' positions.
How can I improve raycasting performance?
Limit the number of objects passed to the raycaster by using layers or a specialized array, and implement a debounce or throttle function on your mouse event listeners.
Do I need to update controls manually?
Yes, if you enable features like damping or auto-rotate, you must call the controls.update() method inside your requestAnimationFrame loop.
How do I handle mobile touch inputs?
Capture touchstart events and normalize the touch client coordinates similarly to mouse client coordinates before passing them to the raycaster.

How it compares

This skill automates the complex mathematical transformation between 2D screen pixels and 3D world vectors, preventing the common manual errors associated with projection matrix math.

Source & trust

2.4k stars🕒 Updated 2026-01-19
📄 Full skill instructions — original source: cloudai-x/threejs-skills
# Three.js Interaction

## Quick Start

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";

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

// Raycasting for click detection
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

function onClick(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children);

if (intersects.length > 0) {
console.log("Clicked:", intersects[0].object);
}
}

window.addEventListener("click", onClick);


## Raycaster

### Basic Raycasting

const raycaster = new THREE.Raycaster();

// From camera (mouse picking)
raycaster.setFromCamera(mousePosition, camera);

// From any origin and direction
raycaster.set(origin, direction); // origin: Vector3, direction: normalized Vector3

// Get intersections
const intersects = raycaster.intersectObjects(objects, recursive);

// intersects array contains:
// {
// distance: number, // Distance from ray origin
// point: Vector3, // Intersection point in world coords
// face: Face3, // Intersected face
// faceIndex: number, // Face index
// object: Object3D, // Intersected object
// uv: Vector2, // UV coordinates at intersection
// uv1: Vector2, // Second UV channel
// normal: Vector3, // Interpolated face normal
// instanceId: number // For InstancedMesh
// }


### Mouse Position Conversion

const mouse = new THREE.Vector2();

function updateMouse(event) {
// For full window
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
}

// For specific canvas element
function updateMouseCanvas(event, canvas) {
const rect = canvas.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}


### Touch Support

function onTouchStart(event) {
event.preventDefault();

if (event.touches.length === 1) {
const touch = event.touches[0];
mouse.x = (touch.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(touch.clientY / window.innerHeight) * 2 + 1;

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

if (intersects.length > 0) {
handleSelection(intersects[0]);
}
}
}

renderer.domElement.addEventListener("touchstart", onTouchStart);


### Raycaster Options

const raycaster = new THREE.Raycaster();

// Near/far clipping (default: 0, Infinity)
raycaster.near = 0;
raycaster.far = 100;

// Line/Points precision
raycaster.params.Line.threshold = 0.1;
raycaster.params.Points.threshold = 0.1;

// Layers (only intersect objects on specific layers)
raycaster.layers.set(1);


### Efficient Raycasting

// Only check specific objects
const clickables = [mesh1, mesh2, mesh3];
const intersects = raycaster.intersectObjects(clickables, false);

// Use layers for filtering
mesh1.layers.set(1); // Clickable layer
raycaster.layers.set(1);

// Throttle raycast for hover effects
let lastRaycast = 0;
function onMouseMove(event) {
const now = Date.now();
if (now - lastRaycast < 50) return; // 20fps max
lastRaycast = now;

// Raycast here
}


## Camera Controls

### OrbitControls

import { OrbitControls } from "three/addons/controls/OrbitControls.js";

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

// Damping (smooth movement)
controls.enableDamping = true;
controls.dampingFactor = 0.05;

// Rotation limits
controls.minPolarAngle = 0; // Top
controls.maxPolarAngle = Math.PI / 2; // Horizon
controls.minAzimuthAngle = -Math.PI / 4; // Left
controls.maxAzimuthAngle = Math.PI / 4; // Right

// Zoom limits
controls.minDistance = 2;
controls.maxDistance = 50;

// Enable/disable features
controls.enableRotate = true;
controls.enableZoom = true;
controls.enablePan = true;

// Auto-rotate
controls.autoRotate = true;
controls.autoRotateSpeed = 2.0;

// Target (orbit point)
controls.target.set(0, 1, 0);

// Update in animation loop
function animate() {
controls.update(); // Required for damping and auto-rotate
renderer.render(scene, camera);
}


### FlyControls

import { FlyControls } from "three/addons/controls/FlyControls.js";

const controls = new FlyControls(camera, renderer.domElement);
controls.movementSpeed = 10;
controls.rollSpeed = Math.PI / 24;
controls.dragToLook = true;

// Update with delta
function animate() {
controls.update(clock.getDelta());
renderer.render(scene, camera);
}


### FirstPersonControls

import { FirstPersonControls } from "three/addons/controls/FirstPersonControls.js";

const controls = new FirstPersonControls(camera, renderer.domElement);
controls.movementSpeed = 10;
controls.lookSpeed = 0.1;
controls.lookVertical = true;
controls.constrainVertical = true;
controls.verticalMin = Math.PI / 4;
controls.verticalMax = (Math.PI * 3) / 4;

function animate() {
controls.update(clock.getDelta());
}


### PointerLockControls

import { PointerLockControls } from "three/addons/controls/PointerLockControls.js";

const controls = new PointerLockControls(camera, document.body);

// Lock pointer on click
document.addEventListener("click", () => {
controls.lock();
});

controls.addEventListener("lock", () => {
console.log("Pointer locked");
});

controls.addEventListener("unlock", () => {
console.log("Pointer unlocked");
});

// Movement
const velocity = new THREE.Vector3();
const direction = new THREE.Vector3();
const moveForward = false;
const moveBackward = false;

document.addEventListener("keydown", (event) => {
switch (event.code) {
case "KeyW":
moveForward = true;
break;
case "KeyS":
moveBackward = true;
break;
}
});

function animate() {
if (controls.isLocked) {
direction.z = Number(moveForward) - Number(moveBackward);
direction.normalize();

velocity.z -= direction.z * 0.1;
velocity.z *= 0.9; // Friction

controls.moveForward(-velocity.z);
}
}


### TrackballControls

import { TrackballControls } from "three/addons/controls/TrackballControls.js";

const controls = new TrackballControls(camera, renderer.domElement);
controls.rotateSpeed = 2.0;
controls.zoomSpeed = 1.2;
controls.panSpeed = 0.8;
controls.staticMoving = true;

function animate() {
controls.update();
}


### MapControls

import { MapControls } from "three/addons/controls/MapControls.js";

const controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.maxPolarAngle = Math.PI / 2;


## TransformControls

Gizmo for moving/rotating/scaling objects.

import { TransformControls } from "three/addons/controls/TransformControls.js";

const transformControls = new TransformControls(camera, renderer.domElement);
scene.add(transformControls);

// Attach to object
transformControls.attach(selectedMesh);

// Switch modes
transformControls.setMode("translate"); // 'translate', 'rotate', 'scale'

// Change space
transformControls.setSpace("local"); // 'local', 'world'

// Size
transformControls.setSize(1);

// Events
transformControls.addEventListener("dragging-changed", (event) => {
// Disable orbit controls while dragging
orbitControls.enabled = !event.value;
});

transformControls.addEventListener("change", () => {
renderer.render(scene, camera);
});

// Keyboard shortcuts
window.addEventListener("keydown", (event) => {
switch (event.key) {
case "g":
transformControls.setMode("translate");
break;
case "r":
transformControls.setMode("rotate");
break;
case "s":
transformControls.setMode("scale");
break;
case "Escape":
transformControls.detach();
break;
}
});


## DragControls

Drag objects directly.

import { DragControls } from "three/addons/controls/DragControls.js";

const draggableObjects = [mesh1, mesh2, mesh3];
const dragControls = new DragControls(
draggableObjects,
camera,
renderer.domElement,
);

dragControls.addEventListener("dragstart", (event) => {
orbitControls.enabled = false;
event.object.material.emissive.set(0xaaaaaa);
});

dragControls.addEventListener("drag", (event) => {
// Constrain to ground plane
event.object.position.y = 0;
});

dragControls.addEventListener("dragend", (event) => {
orbitControls.enabled = true;
event.object.material.emissive.set(0x000000);
});


## Selection System

### Click to Select

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let selectedObject = null;

function onMouseDown(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(selectableObjects);

// Deselect previous
if (selectedObject) {
selectedObject.material.emissive.set(0x000000);
}

// Select new
if (intersects.length > 0) {
selectedObject = intersects[0].object;
selectedObject.material.emissive.set(0x444444);
} else {
selectedObject = null;
}
}


### Box Selection

import { SelectionBox } from "three/addons/interactive/SelectionBox.js";
import { SelectionHelper } from "three/addons/interactive/SelectionHelper.js";

const selectionBox = new SelectionBox(camera, scene);
const selectionHelper = new SelectionHelper(renderer, "selectBox"); // CSS class

document.addEventListener("pointerdown", (event) => {
selectionBox.startPoint.set(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
0.5,
);
});

document.addEventListener("pointermove", (event) => {
if (selectionHelper.isDown) {
selectionBox.endPoint.set(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
0.5,
);
}
});

document.addEventListener("pointerup", (event) => {
selectionBox.endPoint.set(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
0.5,
);

const selected = selectionBox.select();
console.log("Selected objects:", selected);
});


### Hover Effects

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let hoveredObject = null;

function onMouseMove(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(hoverableObjects);

// Reset previous hover
if (hoveredObject) {
hoveredObject.material.color.set(hoveredObject.userData.originalColor);
document.body.style.cursor = "default";
}

// Apply new hover
if (intersects.length > 0) {
hoveredObject = intersects[0].object;
if (!hoveredObject.userData.originalColor) {
hoveredObject.userData.originalColor =
hoveredObject.material.color.getHex();
}
hoveredObject.material.color.set(0xff6600);
document.body.style.cursor = "pointer";
} else {
hoveredObject = null;
}
}

window.addEventListener("mousemove", onMouseMove);


## Keyboard Input

const keys = {};

document.addEventListener("keydown", (event) => {
keys[event.code] = true;
});

document.addEventListener("keyup", (event) => {
keys[event.code] = false;
});

function update() {
const speed = 0.1;

if (keys["KeyW"]) player.position.z -= speed;
if (keys["KeyS"]) player.position.z += speed;
if (keys["KeyA"]) player.position.x -= speed;
if (keys["KeyD"]) player.position.x += speed;
if (keys["Space"]) player.position.y += speed;
if (keys["ShiftLeft"]) player.position.y -= speed;
}


## World-Screen Coordinate Conversion

### World to Screen

function worldToScreen(position, camera) {
const vector = position.clone();
vector.project(camera);

return {
x: ((vector.x + 1) / 2) * window.innerWidth,
y: (-(vector.y - 1) / 2) * window.innerHeight,
};
}

// Position HTML element over 3D object
const screenPos = worldToScreen(mesh.position, camera);
element.style.left = screenPos.x + "px";
element.style.top = screenPos.y + "px";


### Screen to World

function screenToWorld(screenX, screenY, camera, targetZ = 0) {
const vector = new THREE.Vector3(
(screenX / window.innerWidth) * 2 - 1,
-(screenY / window.innerHeight) * 2 + 1,
0.5,
);

vector.unproject(camera);

const dir = vector.sub(camera.position).normalize();
const distance = (targetZ - camera.position.z) / dir.z;

return camera.position.clone().add(dir.multiplyScalar(distance));
}


### Ray-Plane Intersection

function getRayPlaneIntersection(mouse, camera, plane) {
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);

const intersection = new THREE.Vector3();
raycaster.ray.intersectPlane(plane, intersection);

return intersection;
}

// Ground plane
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const worldPos = getRayPlaneIntersection(mouse, camera, groundPlane);


## Event Handling Best Practices

class InteractionManager {
constructor(camera, renderer, scene) {
this.camera = camera;
this.renderer = renderer;
this.scene = scene;
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.clickables = [];

this.bindEvents();
}

bindEvents() {
const canvas = this.renderer.domElement;

canvas.addEventListener("click", (e) => this.onClick(e));
canvas.addEventListener("mousemove", (e) => this.onMouseMove(e));
canvas.addEventListener("touchstart", (e) => this.onTouchStart(e));
}

updateMouse(event) {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}

getIntersects() {
this.raycaster.setFromCamera(this.mouse, this.camera);
return this.raycaster.intersectObjects(this.clickables, true);
}

onClick(event) {
this.updateMouse(event);
const intersects = this.getIntersects();

if (intersects.length > 0) {
const object = intersects[0].object;
if (object.userData.onClick) {
object.userData.onClick(intersects[0]);
}
}
}

addClickable(object, callback) {
this.clickables.push(object);
object.userData.onClick = callback;
}

dispose() {
// Remove event listeners
}
}

// Usage
const interaction = new InteractionManager(camera, renderer, scene);
interaction.addClickable(mesh, (intersect) => {
console.log("Clicked at:", intersect.point);
});


## Performance Tips

1. **Limit raycasts**: Throttle mousemove handlers
2. **Use layers**: Filter raycast targets
3. **Simple collision meshes**: Use invisible simpler geometry for raycasting
4. **Disable controls when not needed**: controls.enabled = false
5. **Batch updates**: Group interaction checks

// Use simpler geometry for raycasting
const complexMesh = loadedModel;
const collisionMesh = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshBasicMaterial({ visible: false }),
);
collisionMesh.userData.target = complexMesh;
clickables.push(collisionMesh);


## See Also

- threejs-fundamentals - Camera and scene setup
- threejs-animation - Animating interactions
- threejs-shaders - Visual feedback effects

How to Use This Skill Unit

Option A: Project-Specific (Recommended)

  1. Click "Download" above
  2. In your project, create the directory: .agent/skills/threejs-interaction/
  3. Save the file as SKILL.md
  4. The agent will automatically discover the skill based on its description.

Option B: Global Installation (All Agents)

Save the file to these locations to make it available across all projects:

  • Claude Code: ~/.claude/skills/cloudai-x/threejs-skills/threejs-interaction/SKILL.md
  • Cursor: ~/.cursor/skills/cloudai-x/threejs-skills/threejs-interaction/SKILL.md
  • Antigravity: ~/.gemini/antigravity/skills/cloudai-x/threejs-skills/threejs-interaction/SKILL.md

🚀 Install with CLI:
npx skills add cloudai-x/threejs-skills

Read the Master Guide: Mastering Agent Skills

Related Skill Units

Recommended Rules

View more rules

Recommended Workflows

View more workflows

Recommended MCP Servers

View more MCP servers

Take It Further

Maximize your productivity with these powerful resources

📋

Define Your Standards

Set up coding standards to ensure this workflow produces consistent, high-quality results.

Browse Rules Library
📖

Master Workflows

Learn how to create custom workflows, use Turbo Mode, and build your automation library.

Complete Guide

How to use this Skill in Claude Code & Cursor

For Claude Code (CLI)

To use this skill in Claude Code, copy the rule content into your project's custom instructions or follow our Add-Skill CLI guide. This ensures Claude follows your standards during every code generation.

For Cursor & Windsurf

For Cursor or Windsurf, individual skills are best used in the "Rules for AI" section. This specific unit helps the agent avoid ui/ux design issues, leading to cleaner, more efficient code.

Why the skill format matters: the standardized Agent Skills format lets your AI agent load detailed instructions only when they are relevant, keeping your prompt clean while improving results.

Source & attribution

This skill is categorized under UI/UX Design and is published by CloudAI-X, maintained in cloudai-x/threejs-skills.

← Browse All Agent Skills
Sponsored AI assistant. Recommendations may be paid.