ANIMATING 3D
GLTF MODELS

Load a rigged 3D knight from a .glb file with GLTFLoader, then play and smoothly blend its idle, walking, running and fight animations with an AnimationMixer. This is the core skill behind every animated character in a Three.js game.

✓ FREE Level 1 · Beginner GLTFLoaderAnimationMixerCross-fading

By Shane Brumback · Updated November 2, 2026 · about 12 minute read

LIVE DEMO

The knight on the right of this page is the live scene, touring its animation clips every 4 seconds. Launch the full-screen demo to drag to orbit, scroll to zoom and pick a clip with the buttons. Launch asks for a free Google sign-in first. Press Esc or Exit to return.

Free Google sign-in · Orbit: drag · Zoom: scroll · Buttons switch clips · Esc to exit

Introduction

Almost every character you see in a browser game started life in Blender, Maya or Mixamo and reached the web as a glTF file. glTF is called "the JPEG of 3D": one compact file (.glb) that carries the meshes, materials, textures, the skeleton and every animation clip.

In this free tutorial you load a knight with 35 animated bones and several clips, size it automatically so any model fits, and switch between idle, walking, running and fight with smooth cross-fades. Everything you learn transfers directly to your own models.

How It Works (Key Ideas)

  • GLTFLoader — reads the .glb and gives you gltf.scene (the model) and gltf.animations (an array of AnimationClips).
  • AnimationClip — recorded keyframes for the bones, such as "running". It is just data until something plays it.
  • AnimationMixer — the playback engine for one model. You call mixer.update(delta) every frame.
  • AnimationAction — made by mixer.clipAction(clip): play, stop, set speed, loop, fade in and fade out.
  • Cross-fading — fading one action out while another fades in, so the pose blends instead of snapping.
  • Box3 auto-fit — measuring the model so a tiny or giant file always appears 1.8 units tall and standing on the floor.

Common Problems

  • Model is invisible: it is probably huge or tiny. The Box3 step fixes that; also check the console for a 404 on the .glb path.
  • Model loads but does not move: you forgot mixer.update(dt) in the loop, or never called .play().
  • Clip name not found: log gltf.animations.map(c => c.name) to see the real names in your file.
  • Model is black: add lights. MeshStandardMaterial shows nothing without light or an environment map.

Step-by-Step Code Breakdown

Every excerpt below is copied exactly from the full code further down, which is the same code running the live scene on this page.

1. Scene, camera and renderer

In plain words: we make the 3D world, a camera at about head height a few steps back, and a renderer. We switch on shadows so the character grounds itself on the floor, and ACES tone mapping so bright lights look natural instead of blown out.

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f1520);
scene.fog = new THREE.Fog(0x0f1520, 10, 28);

const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 1.7, 5.2);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

2. Lights and a floor

In plain words: a sky-and-ground light fills the scene softly, and one strong sun light casts the shadow. A round dark floor and a faint teal grid give the eye a sense of scale and show off the footwork when the knight walks and runs.

scene.add(new THREE.HemisphereLight(0xdfe9ff, 0x2a2018, 1.4));
const sun = new THREE.DirectionalLight(0xffffff, 2.4);
sun.position.set(3, 6, 4);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
scene.add(sun);

const floor = new THREE.Mesh(
  new THREE.CircleGeometry(6, 64),
  new THREE.MeshStandardMaterial({ color: 0x1b2431, roughness: 0.9 })
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
const grid = new THREE.GridHelper(12, 24, 0x2ad1d6, 0x2a3646);
grid.material.transparent = true;
grid.material.opacity = 0.25;
scene.add(grid);

3. Load the GLB model

In plain words: GLTFLoader downloads the .glb file (a glTF model packed into one binary file: meshes, skeleton, textures and animations together). Models come in all sizes, so we first start the idle clip (the rest pose can be a very different size), then measure the posed model with a Box3 (an invisible box that fits around it), scale it to 1.8 units tall, center it, and stand its feet on the floor.

let mixer = null, current = null;
const actions = {};
new GLTFLoader().load('/models/glb/knight-animations.glb', (gltf) => {
  const model = gltf.scene;
  setupAnimations(model, gltf.animations);   // start idle first (step 4)...
  mixer.update(0.5);                         // ...past its 0.4 s fade-in, so we measure the real pose
  model.updateMatrixWorld(true);
  model.traverse((o) => { if (o.isSkinnedMesh) o.skeleton.update(); });
  const box = new THREE.Box3().setFromObject(model, true);  // true = measure the posed skin
  const s = 1.8 / box.getSize(new THREE.Vector3()).y;
  const center = box.getCenter(new THREE.Vector3());
  model.scale.setScalar(s);
  model.position.set(-center.x * s, -box.min.y * s, -center.z * s);   // feet on the floor
  model.traverse((o) => { if (o.isMesh) o.castShadow = true; });
  scene.add(model);
});

4. Turn clips into actions with an AnimationMixer

In plain words: the file holds several animation clips (idle, walking, running, fight...). An AnimationMixer is the player for one model. clipAction() wraps each clip in a play button we can start, stop and blend. play() fades the new clip in while the old one fades out over 0.4 seconds, so the knight never snaps from one pose to the next.

function setupAnimations(model, clips) {
  mixer = new THREE.AnimationMixer(model);
  for (const clip of clips) actions[clip.name] = mixer.clipAction(clip);
  play('idle');
}
function play(name) {
  const next = actions[name];
  if (!next || next === current) return;
  next.reset().fadeIn(0.4).play();   // blend the new clip in...
  if (current) current.fadeOut(0.4); // ...while the old one blends out
  current = next;
}

5. Buttons to switch animations

In plain words: we add one button per clip. Clicking a button stops the automatic tour and plays that clip right away, so you can compare idle, walk, run and fight yourself.

const order = ['idle', 'walking', 'running', 'fight'];
let auto = true, tour = 0, nextSwitch = 4;
const ui = document.getElementById('ui');
for (const name of order) {
  const b = document.createElement('button');
  b.textContent = name;
  b.onclick = () => { auto = false; play(name); };
  ui.appendChild(b);
}

6. Run the render loop

In plain words: every frame we ask the Clock how many seconds passed and hand that to mixer.update(), which moves the skeleton forward in its animation. Without that one line the model would stand frozen. Every 4 seconds the automatic tour plays the next clip.

const clock = new THREE.Clock();
function animate() {
  requestAnimationFrame(animate);
  const dt = Math.min(clock.getDelta(), 0.05);
  if (mixer) mixer.update(dt);              // advance the animation
  if (auto && mixer && clock.elapsedTime > nextSwitch) {
    nextSwitch = clock.elapsedTime + 4;
    play(order[++tour % order.length]);     // automatic tour of the clips
  }
  controls.update();
  renderer.render(scene, camera);
}
animate();

The Full Code

Paste this into a file named index.html. Serve it from a local web server with the model at /models/glb/knight-animations.glb (or point the loader at your own .glb). Launch Full Code opens this exact code as a standalone page in a new window.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Three.js GLTF Animation Demo</title>
  <style>
    body { margin: 0; overflow: hidden; background: #000; font-family: system-ui, sans-serif; }
    #ui { position: fixed; left: 16px; bottom: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
    #ui button { padding: 10px 16px; border: 1px solid rgba(42,209,214,0.5); border-radius: 8px; background: rgba(10,15,28,0.85); color: #fff; font: 600 14px system-ui, sans-serif; text-transform: capitalize; cursor: pointer; }
    #ui button:hover { background: #2ad1d6; color: #06252a; }
  </style>
  <script type="importmap">
  {
    "imports": {
      "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
      "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
    }
  }
  </script>
</head>
<body>
<div id="ui"></div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

// --- 1. Scene, camera and renderer ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f1520);
scene.fog = new THREE.Fog(0x0f1520, 10, 28);

const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 1.7, 5.2);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

// Orbit controls: drag (mouse or touch) to orbit. Zoom is off on the page so
// vertical swipes still scroll on phones; the full-screen demo turns zoom on.
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0);
controls.enableDamping = true;
controls.enableZoom = false;
controls.enablePan = false;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.6;
renderer.domElement.style.touchAction = 'pan-y';

// --- 2. Lights and a floor ---
scene.add(new THREE.HemisphereLight(0xdfe9ff, 0x2a2018, 1.4));
const sun = new THREE.DirectionalLight(0xffffff, 2.4);
sun.position.set(3, 6, 4);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
scene.add(sun);

const floor = new THREE.Mesh(
  new THREE.CircleGeometry(6, 64),
  new THREE.MeshStandardMaterial({ color: 0x1b2431, roughness: 0.9 })
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
const grid = new THREE.GridHelper(12, 24, 0x2ad1d6, 0x2a3646);
grid.material.transparent = true;
grid.material.opacity = 0.25;
scene.add(grid);

// --- 3. Load the GLB model ---
let mixer = null, current = null;
const actions = {};
new GLTFLoader().load('/models/glb/knight-animations.glb', (gltf) => {
  const model = gltf.scene;
  setupAnimations(model, gltf.animations);   // start idle first (step 4)...
  mixer.update(0.5);                         // ...past its 0.4 s fade-in, so we measure the real pose
  model.updateMatrixWorld(true);
  model.traverse((o) => { if (o.isSkinnedMesh) o.skeleton.update(); });
  const box = new THREE.Box3().setFromObject(model, true);  // true = measure the posed skin
  const s = 1.8 / box.getSize(new THREE.Vector3()).y;
  const center = box.getCenter(new THREE.Vector3());
  model.scale.setScalar(s);
  model.position.set(-center.x * s, -box.min.y * s, -center.z * s);   // feet on the floor
  model.traverse((o) => { if (o.isMesh) o.castShadow = true; });
  scene.add(model);
});

// --- 4. Turn clips into actions with an AnimationMixer ---
function setupAnimations(model, clips) {
  mixer = new THREE.AnimationMixer(model);
  for (const clip of clips) actions[clip.name] = mixer.clipAction(clip);
  play('idle');
}
function play(name) {
  const next = actions[name];
  if (!next || next === current) return;
  next.reset().fadeIn(0.4).play();   // blend the new clip in...
  if (current) current.fadeOut(0.4); // ...while the old one blends out
  current = next;
}

// --- 5. Buttons to switch animations ---
const order = ['idle', 'walking', 'running', 'fight'];
let auto = true, tour = 0, nextSwitch = 4;
const ui = document.getElementById('ui');
for (const name of order) {
  const b = document.createElement('button');
  b.textContent = name;
  b.onclick = () => { auto = false; play(name); };
  ui.appendChild(b);
}

// --- 6. Run the render loop ---
const clock = new THREE.Clock();
function animate() {
  requestAnimationFrame(animate);
  const dt = Math.min(clock.getDelta(), 0.05);
  if (mixer) mixer.update(dt);              // advance the animation
  if (auto && mixer && clock.elapsedTime > nextSwitch) {
    nextSwitch = clock.elapsedTime + 4;
    play(order[++tour % order.length]);     // automatic tour of the clips
  }
  controls.update();
  renderer.render(scene, camera);
}
animate();

addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>

What To Try Next

  • Change the cross-fade time from 0.4 to 1.5 to see how blending length changes the feel.
  • Use action.setEffectiveTimeScale(0.5) to play the run in slow motion.
  • Bind clips to keys: W plays walking, Shift plays running, Space plays fight. Then move the model with the keys, as in the character controller tutorial.
  • Load your own Mixamo character exported as .glb and print its clip names.

Frequently Asked Questions

How do you animate a GLTF model in Three.js?

Load it with GLTFLoader, create a THREE.AnimationMixer for gltf.scene, turn a clip from gltf.animations into an action with mixer.clipAction(clip), call action.play(), and call mixer.update(delta) every frame with the seconds from a THREE.Clock.

What is the difference between GLTF and GLB?

Both are the glTF 2.0 format. A .gltf file is JSON that points to separate .bin and texture files. A .glb packs everything into one binary file, which is easier to host and usually loads faster.

How do I switch smoothly between two animations?

Call next.reset().fadeIn(0.4).play() on the new action and current.fadeOut(0.4) on the old one, or use current.crossFadeTo(next, 0.4). Both actions play at once while their weights blend.

Why is my GLTF model not showing?

The usual causes are a wrong file path (check the network tab for a 404), a model far too big or small for the camera (fit it with a Box3), no lights for a MeshStandardMaterial, or opening the page from file:// instead of a web server.

Is this GLTF animation tutorial free?

Yes. It is completely free. The full code is on the page and opens in a new window with Launch Full Code. The full-screen live demo only asks for a free Google sign-in.