From clicking to dragging
Clicking tells you what was hit. Dragging tells you where to move it. The challenge is that a mouse moves in 2D (across the screen) while your objects live in 3D. You need a surface to project onto. Without one, the object could move anywhere along the ray, which isn't useful.
The standard approach is to project onto an invisible drag plane. When the user presses down on an object, you create a plane at the object's depth (perpendicular to the camera), then on each mousemove you intersect the ray with that plane to get the new world position.
The drag lifecycle
A drag interaction has three phases:
- pointerdown: Raycast to find the object under the cursor. If one is hit, store it as the dragged object and create a drag plane at its position.
- pointermove: While dragging, raycast against the drag plane (not the scene objects) to get the current world position. Move the object to match.
- pointerup: Release the object. Clear the drag state.
Basic drag on a plane
The simplest version drags objects along a horizontal ground plane (the XZ plane at y=0). Click and drag the shapes below:
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const intersection = new THREE.Vector3();
let draggedObject = null;
let dragOffset = new THREE.Vector3();
canvas.addEventListener('pointerdown', (event) => {
updateMouse(event);
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(draggableObjects);
if (hits.length > 0) {
draggedObject = hits[0].object;
// Calculate offset so the object doesn't jump to the cursor
raycaster.ray.intersectPlane(dragPlane, intersection);
dragOffset.copy(intersection).sub(draggedObject.position);
canvas.style.cursor = 'grabbing';
}
});
canvas.addEventListener('pointermove', (event) => {
if (!draggedObject) return;
updateMouse(event);
raycaster.setFromCamera(mouse, camera);
raycaster.ray.intersectPlane(dragPlane, intersection);
draggedObject.position.copy(intersection.sub(dragOffset));
});
canvas.addEventListener('pointerup', () => {
draggedObject = null;
canvas.style.cursor = 'default';
});The key detail here is the drag offset. Without it, the object snaps so its center is directly under the cursor the moment you start dragging. The offset preserves the distance between where you clicked and the object's origin, so the drag feels natural.
Also notice that during the drag we raycast against the dragPlane, not the scene objects. This is faster and more reliable. If you raycast against the object itself while moving it, you can lose the intersection when the mouse moves faster than the object can follow.
Dragging at the object's depth
The ground plane approach works for top-down or strategy-game-style views. But what if your objects are floating in space and you want to drag them parallel to the camera? You create the drag plane dynamically when the drag starts, positioned at the object and facing the camera:
canvas.addEventListener('pointerdown', (event) => {
updateMouse(event);
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(draggableObjects);
if (hits.length > 0) {
draggedObject = hits[0].object;
// Create a plane at the object's position, facing the camera
const cameraDirection = new THREE.Vector3();
camera.getWorldDirection(cameraDirection);
dragPlane.setFromNormalAndCoplanarPoint(
cameraDirection,
draggedObject.position
);
raycaster.ray.intersectPlane(dragPlane, intersection);
dragOffset.copy(intersection).sub(draggedObject.position);
}
});This creates a plane that's always perpendicular to the camera's view direction, passing through the object. The object slides along this plane as you drag, maintaining its apparent depth in the scene.
Constrained dragging
Unconstrained dragging lets objects go anywhere, which is sometimes exactly what you want. But often you need to restrict movement: to a single axis, to a grid, or within a boundary. Try dragging the objects below. The red one is locked to the X axis, the green to Z, and the blue snaps to a grid:
Axis locking
To lock movement to a single axis, take the full intersection point and only apply the component you care about:
// Lock to X axis only
draggedObject.position.x = intersection.x - dragOffset.x;
// Y and Z stay at their original values
// Lock to Z axis only
draggedObject.position.z = intersection.z - dragOffset.z;
// Lock to XZ plane (free horizontal movement, no vertical)
draggedObject.position.x = intersection.x - dragOffset.x;
draggedObject.position.z = intersection.z - dragOffset.z;Grid snapping
To snap to a grid, round the position to the nearest grid unit after calculating the drag position:
const gridSize = 1.0;
function snapToGrid(value, size) {
return Math.round(value / size) * size;
}
// After calculating the new position:
draggedObject.position.x = snapToGrid(intersection.x - dragOffset.x, gridSize);
draggedObject.position.z = snapToGrid(intersection.z - dragOffset.z, gridSize);You can combine grid snapping with axis locking. A chess game would lock to the XZ plane with a grid size matching the tile spacing. A side-scrolling level editor might lock to X with vertical grid snapping.
Boundary clamping
To keep objects within bounds, clamp the position after calculating it:
const bounds = { minX: -4, maxX: 4, minZ: -4, maxZ: 4 };
draggedObject.position.x = THREE.MathUtils.clamp(
intersection.x - dragOffset.x,
bounds.minX, bounds.maxX
);
draggedObject.position.z = THREE.MathUtils.clamp(
intersection.z - dragOffset.z,
bounds.minZ, bounds.maxZ
);Drop zones
A drop zone is a region where releasing a dragged object triggers some action: placing an item in an inventory slot, positioning a piece on a game board, or snapping a component into a socket. In the scene below, drag the shapes into the glowing target zones:
const dropZones = []; // Array of { mesh, position, radius }
canvas.addEventListener('pointerup', () => {
if (!draggedObject) return;
// Check if the object is inside any drop zone
for (const zone of dropZones) {
const distance = draggedObject.position.distanceTo(zone.position);
if (distance < zone.radius) {
// Snap to the zone's center
draggedObject.position.copy(zone.position);
draggedObject.position.y = draggedObject.position.y; // keep height
onDrop(draggedObject, zone);
break;
}
}
draggedObject = null;
canvas.style.cursor = 'default';
});
function onDrop(object, zone) {
// Handle the drop: update state, play animation, etc.
console.log(object.name, 'dropped in zone', zone.mesh.name);
}The distance check is the simplest approach. For rectangular zones, you'd check whether the position falls within a bounding box instead. For complex shapes, you could use the zone mesh's bounding geometry.
TransformControls
Three.js ships with TransformControls, a built-in gizmo that provides translate, rotate, and scale handles. It's the same kind of widget you'd see in Blender or Unity's editor. You don't need to implement dragging yourself. Instead, you attach the controls to an object and it handles the interaction.
import { TransformControls } from 'three/addons/controls/TransformControls.js';
const controls = new TransformControls(camera, renderer.domElement);
controls.attach(selectedObject);
scene.add(controls);
// Switch between modes
controls.setMode('translate'); // move
controls.setMode('rotate'); // rotate
controls.setMode('scale'); // scale
// Listen for changes
controls.addEventListener('change', () => {
renderer.render(scene, camera);
});
// Disable OrbitControls while dragging the gizmo
controls.addEventListener('dragging-changed', (event) => {
orbitControls.enabled = !event.value;
});The dragging-changed event is important if you're using OrbitControls. Without it, dragging the transform gizmo would also rotate the camera, which isn't the behavior you want.
TransformControls is ideal for editor-style tools, level builders, or debug interfaces where you need precise manipulation of objects. For game-like drag interactions (inventory systems, puzzle placement), the manual approach from earlier in this article gives you more control over the feel and constraints.
Putting it together
The pattern for drag interactions in Three.js is consistent regardless of complexity:
- On
pointerdown: raycast to pick the object, create a drag plane, calculate the offset - On
pointermove: raycast against the drag plane, apply constraints, update position - On
pointerup: check for drop zones, finalize placement, clear state
Everything else is variation on this structure. Axis locking, grid snapping, boundary clamping, drop zone detection. These are all modifications to step 2 or 3. The core mechanic stays the same.