Three.js animates in three main ways: per-frame property updates inside the render loop, AnimationMixer playing back authored clips (from glTF, FBX, etc.), and morph targets / skinned meshes for character rigs. This post covers all three plus the tween libraries (@tweenjs/tween.js, GSAP) that make property animation pleasant, frame timing rules (always use delta time, never frame count), and the standard mixer recipes — cross-fade, additive blends, time-scale, weights.

Series map: 00 Foundations → 01 Geometry & Materials → 02 Cameras & Controls → 03 Lighting & Shadows → 04 Textures & PBR → 05 Asset Pipeline → 06 Animation (this post) → 07 Instancing & Batching → 08 Low-End Perf → 09 Shaders → 10 Post-Processing → 11 Physics → 12 R3F → 13 AI 3D.


Learning Outcome

  • Animate properties safely with requestAnimationFrame + delta time.
  • Play glTF animation clips using AnimationMixer.
  • Cross-fade, blend, weight multiple clips.
  • Use morph targets (blend shapes) for facial / shape interpolation.
  • Use skinned meshes with bones (SkeletonHelper).
  • Use @tweenjs/tween.js or GSAP for declarative property animation.

Chapter 1 — Delta-time fundamentals

The animation loop runs at the browser's frame rate — usually 60 Hz, sometimes 120 Hz on modern displays, sometimes 30 Hz on a struggling phone. Animation logic must be frame-rate independent.

1
2
3
4
5
6
7
8
9
10
import * as THREE from 'three';

const clock = new THREE.Clock();

renderer.setAnimationLoop(() => {
const dt = clock.getDelta(); // seconds since last frame
cube.rotation.y += 1.0 * dt; // 1 radian per second, every device
renderer.render(scene, camera);
});

Never write cube.rotation.y += 0.01 — that's "0.01 radians per frame", which means twice as fast at 120 Hz and half as fast at 30 Hz. Always multiply by dt.

clock.getElapsedTime() returns total seconds since the clock started — great for sin-wave motion:

1
2
3
const t = clock.getElapsedTime();
cube.position.y = Math.sin(t * 2) * 0.5;

Chapter 2 — Direct property animation

For simple effects, mutate properties each frame:

1
2
3
4
5
6
7
8
9
renderer.setAnimationLoop(() => {
const t = clock.getElapsedTime();
cube.rotation.x = t * 0.5;
cube.rotation.y = t * 0.7;
light.position.x = Math.cos(t) * 5;
light.position.z = Math.sin(t) * 5;
renderer.render(scene, camera);
});

Cheap, no library needed. Right answer for one-off effects.


Chapter 3 — Tween libraries

For declarative "go from A to B over T seconds with easing", a tween library is cleaner:

1
2
npm install @tweenjs/tween.js
1
2
3
4
5
6
7
8
9
10
11
12
import { Tween, Easing, update as updateTweens } from '@tweenjs/tween.js';

new Tween(cube.position)
.to({ x: 5, y: 2, z: 0 }, 1500) // ms
.easing(Easing.Quadratic.InOut)
.start();

renderer.setAnimationLoop(() => {
updateTweens(); // tween.js needs a heartbeat
renderer.render(scene, camera);
});

GSAP is the heavyweight alternative — more features (timelines, scroll triggers, plugins) but a paid Club license for some features. For Three.js scenes, @tweenjs/tween.js is usually enough.

Common easings

Linear, Quadratic.In/Out/InOut, Cubic.*, Quartic.*, Quintic.*, Sinusoidal.*, Exponential.*, Circular.*, Elastic.*, Back.*, Bounce.*.

Quadratic.InOut (s-curve, slow ends, fast middle) is the safe default for UI motion. Cubic.Out (fast start, slow end) is good for "object arrives at destination".


Chapter 4 — AnimationMixer + Clips

Authored animation (in Blender, Maya, etc.) comes back from GLTFLoader as gltf.animations — an array of AnimationClip objects. Play them with AnimationMixer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const gltf = await new GLTFLoader().loadAsync('/character.glb');
scene.add(gltf.scene);

const mixer = new THREE.AnimationMixer(gltf.scene);
const idle = mixer.clipAction(THREE.AnimationClip.findByName(gltf.animations, 'Idle')).play();

const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
mixer.update(clock.getDelta());
renderer.render(scene, camera);
});

One AnimationMixer per character. It animates whatever properties the clip targets — bone transforms, morph influences, mesh visibility, even materials.

AnimationAction methods

1
2
3
4
5
6
7
8
9
10
11
const action = mixer.clipAction(clip);
action.play();
action.stop();
action.reset();
action.setLoop(THREE.LoopOnce); // or LoopRepeat (default) / LoopPingPong
action.clampWhenFinished = true; // hold last frame after finishing
action.timeScale = 0.5; // half speed
action.weight = 0.7; // 0-1 contribution when blending
action.setEffectiveTimeScale(...);
action.setEffectiveWeight(...);

Chapter 5 — Cross-fade between clips

The bread-and-butter character pattern:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const walk = mixer.clipAction(walkClip);
const run = mixer.clipAction(runClip);

walk.play();

function startRun() {
run.reset().play();
walk.crossFadeTo(run, 0.3, true); // 0.3 s, warp time
}
function stopRun() {
walk.reset().play();
run.crossFadeTo(walk, 0.3, true);
}

crossFadeTo(other, duration, warp) — warp = true synchronises clip phase (a footstep mid-walk transitions to mid-run, not back to start).

Blending multiple weights

1
2
3
4
5
walk.play();
run.play();
walk.weight = 0.7;
run.weight = 0.3; // 70% walk + 30% run = jog

Common for run/walk transitions driven by a movement-speed value.

AnimationMixer turns clips into actions updated every frame, and a cross-fade from walk to run over 0.3 s

Listening for end

1
2
3
4
mixer.addEventListener('finished', (e) => {
console.log('action finished', e.action);
});

Chapter 6 — Morph targets (blend shapes)

A morph target is an alternate version of a mesh — same vertex count, different positions. Interpolating between morphs animates facial expressions, mouth shapes, body deformations.

A glTF with morph targets exposes mesh.morphTargetInfluences (array of 0-1 weights) and mesh.morphTargetDictionary (name → index).

1
2
3
4
5
6
7
const face = gltf.scene.getObjectByName('FaceMesh');
console.log(face.morphTargetDictionary);
// { mouthOpen: 0, smile: 1, eyesClosed: 2 }

face.morphTargetInfluences[face.morphTargetDictionary.smile] = 1.0;
face.morphTargetInfluences[face.morphTargetDictionary.mouthOpen] = 0.3;

Animate manually or via AnimationMixer (glTF animations target morph influences automatically when authored to).

Costs

Morph targets multiply geometry size by (1 + N) where N = number of targets. A 10k-vertex mesh with 20 expressions = 210k vertices' worth of data. Manageable for a single character; not for crowds.

Use cases

  • Lip-sync for talking characters (a basic phoneme set is 8-12 shapes).
  • Body shape sliders ("character creator").
  • Procedural deformation (wave on a flag — though shaders do this better).

Chapter 7 — Skinned meshes (bones / armature)

A skinned mesh deforms based on a skeleton (a hierarchy of bones). Each vertex is weighted against up to 4 bones; transforming bones deforms the surface.

glTF embeds skin + skeleton + animation clips together; GLTFLoader reconstructs them as THREE.SkinnedMesh + THREE.Skeleton.

1
2
3
4
5
6
gltf.scene.traverse((c) => {
if (c.isSkinnedMesh) {
scene.add(new THREE.SkeletonHelper(c)); // visualise bones for debug
}
});

Skinned animation is what makes characters walk, fight, dance. Authoring happens in Blender / Maya / Houdini; Three.js mostly plays back.

Inverse kinematics (IK)

Three.js has CCDIKSolver (Cyclic Coordinate Descent) for runtime IK — useful for "foot stays planted" or "hand reaches at a target":

1
2
3
4
5
6
7
8
9
import { CCDIKSolver } from 'three/addons/animation/CCDIKSolver.js';

const ikSolver = new CCDIKSolver(skinnedMesh, iks); // iks: bone chain config
renderer.setAnimationLoop(() => {
mixer.update(dt);
ikSolver.update();
renderer.render(scene, camera);
});

For more advanced IK (FABRIK, multi-effector, retargeting), use three-ik or a runtime like Mixamo + retarget tools.


Chapter 8 — Animation retargeting

A clip authored for one skeleton plays on a different skeleton via the SkeletonUtils.retargetClip helper:

1
2
3
4
5
6
import { SkeletonUtils } from 'three/addons/utils/SkeletonUtils.js';

const retargeted = SkeletonUtils.retargetClip(
targetSkeletonRoot, sourceSkeletonRoot, clip, { hip: 'mixamorigHips' }
);

Useful for: applying Mixamo animations to your own characters; reusing one walking animation across many character models.


Chapter 9 — Animation libraries beyond the mixer

Library Use
@tweenjs/tween.js declarative tweens; cheap, simple
gsap feature-rich tweens, timelines
motion (formerly Framer Motion 3D) physics-spring animations, declarative
theatre.js timeline-based, in-browser animation editor
popmotion low-level animation primitives

For complex sequenced animation (cutscenes), theatre.js is impressive — a real animation editor that runs inside your page.


Chapter 10 — Performance tips

  • One mixer per character. Many characters = many mixers + one shared scene; cheap.
  • Cache clipAction — calling it repeatedly with the same clip returns the same action but allocates lookups.
  • Pause off-screen characters — mixer.timeScale = 0 while not in frustum.
  • Skip animations far away — at distance, snap to a single pose; the player won't notice.
  • For huge crowds, use InstancedMesh (Post 07) with GPU-driven animation via a vertex shader sampling a baked animation texture. Three.js doesn't ship this but the pattern is common (see BatchedAnimation / GPU skinning libraries).
  • Cull unused bones — characters often ship with bones for hair / cloth that aren't used; remove in the exporter.

Practice Exercises

Exercise 1 — Delta-time sanity

Rotate a cube with 0.5 * dt. Toggle setTimeout(() => fakeSlow(), ...) to make frames slow. Confirm the cube keeps the same wall-clock rotation speed.

Exercise 2 — Tween a position

Tween a cube from (0, 0, 0) to (5, 2, 0) over 2 s with Cubic.Out easing. On complete, tween back.

Exercise 3 — Play a glTF animation

Load a Mixamo character (free, mixamo.com). Play its "Idle" clip via AnimationMixer.

Exercise 4 — Cross-fade

Same character, two clips: Idle + Run. Press W to start crossFadeTo(run), release to fade back. Use warp: true.

Exercise 5 — Morph targets

Load any model with morphs (Khronos RobotExpressive is good). Drive the morph weights from sliders (HTML range inputs). Confirm shapes blend.

Exercise 6 — Skeleton helper

Add SkeletonHelper to your character. Watch bones move while the animation plays.


Cheat-sheet

What How
Clock const clock = new THREE.Clock(); const dt = clock.getDelta()
Frame-rate independent always multiply by dt
Tween library @tweenjs/tween.js — call TWEEN.update() each frame
Common easing Cubic.Out, Quadratic.InOut
Play glTF clip mixer = new THREE.AnimationMixer(scene); mixer.clipAction(clip).play()
Update mixer mixer.update(dt)
Find clip by name THREE.AnimationClip.findByName(clips, 'Walk')
Loop action.setLoop(THREE.LoopOnce/Repeat/PingPong)
Hold last frame action.clampWhenFinished = true
Cross-fade from.crossFadeTo(to, duration, warp)
Blend weights set action.weight on both
Morph target mesh.morphTargetInfluences[i] = weight
Skinned helpers new THREE.SkeletonHelper(mesh) for debug
IK CCDIKSolver from addons
Retarget animation SkeletonUtils.retargetClip
Big crowds GPU-skinned InstancedMesh (Post 07 + custom shader)

What's next

Post 07 covers instancing and batching — InstancedMesh, BatchedMesh, geometry merging, LOD, and the draw-call budget that decides whether your scene runs at 60 fps or 12 fps. This is the answer to "I have 10,000 trees and the framerate died".


References

Note: this post is in progress. Planned additions: GPU-skinned crowd recipe; lip-sync from audio (Rhubarb); root-motion handling; physics-cloth.