Tutorial · Robotics · Kinematics · Three.js
📅 July 2026 ⏱ ≈ 35 min 🎯 Intermediate – Advanced

Build a 6-DOF Manipulator in Three.js

A six degree-of-freedom robot arm is the workhorse of industrial robotics — six revolute joints give it full position and orientation control over its end-effector. In this tutorial we build one entirely from Three.js primitives: a nested Group hierarchy that does forward kinematics for free, live joint sliders, a gripper, and a small Jacobian-transpose IK solver that lets the arm chase a draggable target.

1. Scene Setup

Standard Three.js boilerplate: a perspective camera, a renderer, and a grid helper so joint rotations are easy to judge visually. The camera sits at an angle looking down at the arm's base, similar to how you'd frame a real workcell.

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

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d1117);

const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(4, 3, 5);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0);

scene.add(new THREE.GridHelper(10, 20, 0x334155, 0x1e293b));
scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 1.2));
const dir = new THREE.DirectionalLight(0xffffff, 1.5);
dir.position.set(3, 5, 2);
scene.add(dir);

2. Defining the Joint Chain

We describe the arm as data first: six joints, each with a link length (distance to the next joint along its local Z after rotation) and a rotation axis, loosely following the same base/ shoulder/elbow/wrist naming used on real industrial arms:

#JointAxisLink length
1Base yawY0.4 (vertical riser)
2Shoulder pitchX1.2
3Elbow pitchX1.0
4Wrist rollZ0.25
5Wrist pitchX0.25
6Wrist yaw (gripper mount)Y0.2
const JOINTS = [
  { name: 'base',     axis: 'y', length: 0.4 },
  { name: 'shoulder', axis: 'x', length: 1.2 },
  { name: 'elbow',    axis: 'x', length: 1.0 },
  { name: 'wristRoll',  axis: 'z', length: 0.25 },
  { name: 'wristPitch', axis: 'x', length: 0.25 },
  { name: 'wristYaw',   axis: 'y', length: 0.2 },
];

3. Building the Object3D Hierarchy

This is the key trick of doing kinematics in Three.js: instead of hand-multiplying transform matrices, you build a nested Group hierarchy where each joint's Group is a child of the previous link's end. Rotating a parent's .rotation automatically re-orients everything nested inside it — Three.js's scene graph is your forward kinematics chain.

function buildArm(joints) {
  const root = new THREE.Group();
  const jointGroups = [];
  let parent = root;

  for (const j of joints) {
    const jointGroup = new THREE.Group(); // this Group's rotation IS the joint angle
    parent.add(jointGroup);
    jointGroups.push(jointGroup);

    // visual link: a box or cylinder drawn from this joint to the next
    const linkMesh = new THREE.Mesh(
      new THREE.CylinderGeometry(0.06, 0.06, j.length, 12),
      new THREE.MeshStandardMaterial({ color: 0xfb923c })
    );
    linkMesh.position.set(0, j.length / 2, 0); // cylinder extends along local +Y
    linkMesh.rotation.z = 0;
    jointGroup.add(linkMesh);

    // the NEXT joint's group is offset by this link's length along local Y
    const linkEnd = new THREE.Group();
    linkEnd.position.set(0, j.length, 0);
    jointGroup.add(linkEnd);

    parent = linkEnd; // next joint attaches here
  }
  return { root, jointGroups, endEffector: parent };
}

const { root, jointGroups, endEffector } = buildArm(JOINTS);
scene.add(root);
Why a Group per joint, not per link: keeping the rotating Group separate from its visual mesh means you can rotate jointGroup.rotation[axis] directly without worrying about the mesh's own offset/pivot — the mesh is just a child decoration of the joint.

4. Forward Kinematics via World Matrices

Because endEffector is a real node in the scene graph, Three.js has already computed its forward kinematics for you every time it updates world matrices. You never need to hand-multiply the chain of Denavit-Hartenberg matrices — just read them back out:

function getEndEffectorPose() {
  endEffector.updateWorldMatrix(true, false); // refresh this node + ancestors
  const position = new THREE.Vector3();
  const quaternion = new THREE.Quaternion();
  endEffector.matrixWorld.decompose(position, quaternion, new THREE.Vector3());
  return { position, quaternion };
}

Note the world orientation comes out as a quaternion directly — see our companion article on rotation matrices & quaternions in robotics for why that's exactly the representation you want for smooth end-effector orientation control.

5. Joint Sliders and Live End-Effector Readout

Six HTML range inputs, one per joint, each writing straight into that joint's local rotation on its rotation axis:

JOINTS.forEach((j, i) => {
  const slider = document.querySelector(`#joint-${i}`);
  slider.addEventListener('input', () => {
    const angle = THREE.MathUtils.degToRad(Number(slider.value));
    jointGroups[i].rotation[j.axis] = angle;

    const { position } = getEndEffectorPose();
    document.querySelector('#readout').textContent =
      `x=${position.x.toFixed(2)} y=${position.y.toFixed(2)} z=${position.z.toFixed(2)}`;
  });
});

6. Adding a Gripper

The gripper is just another child of endEffector — two finger meshes that open and close by translating along local X, fully inheriting the wrist's accumulated position and orientation:

function buildGripper() {
  const group = new THREE.Group();
  const fingerGeo = new THREE.BoxGeometry(0.04, 0.18, 0.04);
  const mat = new THREE.MeshStandardMaterial({ color: 0x34d399 });

  const left = new THREE.Mesh(fingerGeo, mat);
  left.position.set(-0.05, 0.09, 0);
  const right = left.clone();
  right.position.x = 0.05;

  group.add(left, right);
  group.userData.fingers = [left, right];
  return group;
}

const gripper = buildGripper();
endEffector.add(gripper);

function setGripperOpening(t) { // t: 0 (closed) .. 1 (open)
  const [left, right] = gripper.userData.fingers;
  left.position.x = -0.02 - t * 0.05;
  right.position.x = 0.02 + t * 0.05;
}

7. A Simple Jacobian IK Solver

To let the arm chase a draggable target, we compute a numerical Jacobian by finite differences (perturb each joint angle slightly, see how much the end-effector moves) rather than hand-deriving one analytically — much less code, and it works for any joint chain without modification:

function solveIK(target, iterations = 12, alpha = 0.6) {
  const EPS = 1e-4;
  for (let iter = 0; iter < iterations; iter++) {
    const { position: current } = getEndEffectorPose();
    const error = new THREE.Vector3().subVectors(target, current);
    if (error.length() < 0.01) break; // converged

    for (let i = 0; i < jointGroups.length; i++) {
      const axis = JOINTS[i].axis;
      const original = jointGroups[i].rotation[axis];

      // perturb this joint, measure resulting end-effector displacement
      jointGroups[i].rotation[axis] = original + EPS;
      const { position: perturbed } = getEndEffectorPose();
      jointGroups[i].rotation[axis] = original; // restore

      const jCol = new THREE.Vector3().subVectors(perturbed, current).divideScalar(EPS);
      const dTheta = jCol.dot(error); // Jacobian-transpose update for this joint
      jointGroups[i].rotation[axis] = original + alpha * dTheta;
    }
  }
}
Why finite differences instead of an analytic Jacobian: for a 6-joint chain with mixed rotation axes, deriving each column symbolically is fiddly and easy to get wrong. Numerical differentiation costs one extra forward-kinematics evaluation per joint per iteration (6 × 12 = 72 evaluations here) — negligible on modern hardware for a single arm, and it automatically stays correct if you change link lengths or axes.

8. Animation Loop

Finally, tie it together: each frame, if IK mode is active, nudge the joints toward the target, then render.

let ikTarget = new THREE.Vector3(1.2, 1.5, 0.8);
let ikEnabled = true;

function animate() {
  requestAnimationFrame(animate);

  if (ikEnabled) solveIK(ikTarget);

  controls.update();
  renderer.render(scene, camera);
}
animate();

From here, natural extensions include clamping each joint to realistic limits (e.g. ±170° for the base, ±120° for the elbow), swapping the finite-difference Jacobian for the analytic version from our Jacobian IK vs FABRIK comparison, or driving the arm along a full trajectory with quaternion SLERP between waypoint orientations.

Frequently Asked Questions

What will I learn in this tutorial?

Build a 6 degree-of-freedom robot arm in Three.js from scratch: a joint hierarchy of Object3D groups, forward kinematics, joint-angle sliders, and a simple Jacobian transpose IK solver.

What topics are covered in this tutorial?

This tutorial covers: Scene Setup, Defining the Joint Chain, Building the Object3D Hierarchy, Forward Kinematics via World Matrices, Joint Sliders and Live Readout, Adding a Gripper, A Simple Jacobian IK Solver, Animation Loop.

How long does this tutorial take?

This tutorial takes approximately 35 minutes to complete.

What prerequisites do I need before starting?

This is a Intermediate – Advanced-level tutorial — no special preparation beyond basic JavaScript is assumed.