three-pivot-controls
July 19, 2026 · View on GitHub
A gizmo-style pivot control for Three.js — drag to translate, rotate, and scale 3D objects with mouse and touch. Inspired by @react-three/drei's PivotControls, framework-free.
Translate arrows, rotate rings, and scale handles all render at once (no mode switching), laid out in concentric rings so they don't collide. Works with mouse and touch out of the box.

Examples
Run locally:
npm install
npm run dev
Install
npm install three-pivot-controls three
three is a peer dependency — bring your own version (>=0.150.0).
Quick start
import { Scene, PerspectiveCamera, WebGLRenderer, Mesh, BoxGeometry, MeshStandardMaterial } from "three";
import { PivotControls } from "three-pivot-controls";
const scene = new Scene();
const camera = new PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
const renderer = new WebGLRenderer();
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const box = new Mesh(new BoxGeometry(), new MeshStandardMaterial());
scene.add(box);
const pivot = new PivotControls(camera, renderer.domElement);
scene.add(pivot.getHelper());
pivot.attach(box);
renderer.setAnimationLoop(() => {
pivot.update(); // call every frame, before rendering
renderer.render(scene, camera);
});
If you're also using OrbitControls (or similar) on the same canvas, disable it while dragging the gizmo so the two don't fight over the pointer:
pivot.addEventListener("dragstart", () => (orbit.enabled = false));
pivot.addEventListener("dragend", () => (orbit.enabled = true));
API reference
new PivotControls(camera, domElement, options?)
| Option | Type | Default | Description |
|---|---|---|---|
translate / rotate / scale | boolean | true | Whether to build handles for that mode at all. |
space | "local" | "world" | "local" | Orient translate axes / rotate rings to the object, or keep them world-axis-aligned. Scale is always local. |
size | number | 1 | Unitless size multiplier — same convention as three.js's TransformControls.size. |
fixed | boolean | true | Keep a constant on-screen size regardless of camera distance. |
depthTest | boolean | false | Render the gizmo through geometry instead of always on top. |
activeAxes | [boolean, boolean, boolean] | [true, true, true] | Per-axis (x/y/z) enable, applied to translate arrows/planes, rotate rings, and scale handles together. |
disableAxes | boolean | false | Hide the three single-axis translate arrows. |
disableSliders | boolean | false | Hide the three two-axis translate plane handles. |
anchor | [number, number, number] | unset | Pivot point as [x, y, z] in the object's own local bounding box (-1 = min face, 0 = center, 1 = max face per axis). Rotate/scale compensate object.position so this point stays fixed in world space while dragging. Unset pivots on object.position with no bounding-box computation. |
axisColors | { x?, y?, z?: number } | red/green/blue | Override any axis's color (hex number). |
thickness | number | 1 | Multiplier on every handle's radius (and hit area). |
length | number | 1 | Multiplier on how far handles reach from the pivot. |
rotateArc | number | 1 | How much of each rotate ring to draw, as a fraction of a full circle (1 = full ring, 0.5 = half). The arc always spans between the other two axes' positive handles, never the far side. |
scaleWithObject | boolean | false | Also scale the gizmo by the attached object's own .scale (averaged across x/y/z). |
annotations | boolean | false | Show a floating label with the live distance/angle/scale-factor while dragging. |
Methods
getHelper(): Group— the gizmo's rootObject3D. Add it to your scene.attach(object: Object3D): this— start controllingobject.detach(): this— stop controlling the current object and hide the gizmo.getObject(): Object3D | null— the currently attached object, if any.update(): void— call once per frame, before rendering. Syncs the gizmo's position/orientation to the attached object and recomputes its on-screen size.dispose(): void— removes event listeners, restorestouch-action, and disposes gizmo geometries/materials.addEventListener(type, listener)/removeEventListener(type, listener)— see Events below.
Events
"dragstart", "drag", and "dragend" fire on the handle being interacted with. The attached object is already updated in place by the time the listener runs — no need to apply the matrix yourself.
pivot.addEventListener("drag", ({ handle, object, matrix }) => {
console.log(handle.mode, handle.axis); // e.g. "translate", "x"
});
| Field | Type | Description |
|---|---|---|
handle | { mode: "translate" | "rotate" | "scale", axis: string } | Which handle is driving the change, e.g. axis: "x" or axis: "xy" for a translate plane. |
object | Object3D | The controlled object. |
matrix | number[] | object.matrix (local, relative to its parent) as a flat column-major array. |
Notes
- Touch is handled via Pointer Events; the control sets
domElement.style.touchAction = "none"internally so dragging a handle doesn't get hijacked by the browser's scroll/pinch-zoom gestures. - Nested/rotated parent hierarchies are accounted for when computing translate and rotate deltas.
- Scaling always happens around the object's own local axes, matching Three.js's
TransformControlsconvention.
Development
npm run typecheck
npm run build
❤️ Support This Project
If you find this module useful and would like to support its development, you can buy me a ☕.
License
MIT