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.

Demo of dragging the gizmo's translate, rotate, and scale handles on a box, sphere, and Suzanne model

Examples

  • Basic (source) — three boxes, click one to select it; a lil-gui panel tweaks every option live

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?)

OptionTypeDefaultDescription
translate / rotate / scalebooleantrueWhether 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.
sizenumber1Unitless size multiplier — same convention as three.js's TransformControls.size.
fixedbooleantrueKeep a constant on-screen size regardless of camera distance.
depthTestbooleanfalseRender 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.
disableAxesbooleanfalseHide the three single-axis translate arrows.
disableSlidersbooleanfalseHide the three two-axis translate plane handles.
anchor[number, number, number]unsetPivot 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/blueOverride any axis's color (hex number).
thicknessnumber1Multiplier on every handle's radius (and hit area).
lengthnumber1Multiplier on how far handles reach from the pivot.
rotateArcnumber1How 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.
scaleWithObjectbooleanfalseAlso scale the gizmo by the attached object's own .scale (averaged across x/y/z).
annotationsbooleanfalseShow a floating label with the live distance/angle/scale-factor while dragging.

Methods

  • getHelper(): Group — the gizmo's root Object3D. Add it to your scene.
  • attach(object: Object3D): this — start controlling object.
  • 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, restores touch-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"
});
FieldTypeDescription
handle{ mode: "translate" | "rotate" | "scale", axis: string }Which handle is driving the change, e.g. axis: "x" or axis: "xy" for a translate plane.
objectObject3DThe controlled object.
matrixnumber[]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 TransformControls convention.

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