Animation Systems

three.js animations
The Animate covered animating Three.js objects with GSAP — which is great for one-off tweens and sequenced timelines. But Three.js has its own built-in animation systems that you'll want when working with GLTF models, morph targets, or anything that came out of Blender.
There are three main approaches, each for a different job:
| Approach | Best for |
|---|---|
| Render loop + math | Simple looping motion — bouncing, orbiting, floating |
| AnimationMixer + clips | GLTF models with pre-made animations (walk, run, idle) |
| Morph targets | Face expressions, shape transitions, blend shapes |
the render loop — your foundation
Every Three.js app has a render loop — that requestAnimationFrame function that runs 60 times a second. It's the simplest animation tool you have.
const clock = new THREE.Clock();
function animate() {
const delta = clock.getDelta(); // seconds since last frame
const elapsed = clock.getElapsedTime(); // total seconds since start
// Basic rotation
mesh.rotation.y += delta;
// Sine-wave motion
mesh.position.y = Math.sin(elapsed * 2) * 0.5;
// Combined — orbiting
const radius = 3;
mesh.position.x = Math.cos(elapsed) * radius;
mesh.position.z = Math.sin(elapsed) * radius;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();
delta vs elapsed
- delta — time since the last frame. Use this for frame-rate-independent motion (like rotation speed). If you use a fixed value like
0.01, the animation slows down when the framerate drops. - elapsed — total time since the start. Use this for looping patterns (sine waves, orbits) that need a continuous time value.
// ❌ BAD — slows down when fps drops
mesh.rotation.y += 0.01;
// ✅ GOOD — framerate-independent
mesh.rotation.y += delta;
things you can do with just math
The render loop + a little trigonometry can take you surprisingly far:
// Floating bobbing motion
mesh.position.y = Math.sin(elapsed * 1.5) * 0.3;
// Pulsing scale
mesh.scale.setScalar(1 + Math.sin(elapsed * 3) * 0.1);
// Color cycling
mesh.material.color.setHSL((elapsed * 0.1) % 1, 1, 0.5);
// Spinning that gradually slows down
let speed = 1;
mesh.rotation.y += delta * speed;
speed *= 0.999; // tiny slowdown each frame
No libraries. No extra setup. Just the render loop and a bit of Math.
AnimationMixer — animation for glTF models
If you've ever downloaded a 3D model from Sketchfab or Poly Pizza, it probably came as a .glb or .gltf file with animations baked in — a character that waves, a robot that dances, a car with spinning wheels.
Three.js has a built-in animation system to play those: AnimationMixer, AnimationClip, and AnimationAction.
Think of it like this:
| Concept | What it is |
|---|---|
| AnimationClip | The recorded animation data — keyframes of positions, rotations, scales |
| AnimationMixer | The "player" that controls one or more clips on a model |
| AnimationAction | The "remote control" for a single clip — play, pause, speed, loop |
loading and playing a model's animations
Here's the full flow:
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
loader.load('/models/robot.glb', (gltf) => {
const model = gltf.scene;
scene.add(model);
// 1. Create the mixer for this model
const mixer = new THREE.AnimationMixer(model);
// 2. Grab the first animation clip from the file
const clip = gltf.animations[0]; // e.g. "wave" or "idle"
// 3. Create an action and play it
const action = mixer.clipAction(clip);
action.play();
// 4. Store mixer so your render loop can update it
window.mixer = mixer; // or add to your animation loop's scope
});
Then in your render loop:
function animate() {
const delta = clock.getDelta();
// Update ALL active mixers
if (window.mixer) window.mixer.update(delta);
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
⚠️ critical: If you forget
mixer.update(delta)in your render loop, nothing will animate. This is the #1 mistake people make.
controlling playback
Once you have an AnimationAction, you have full control:
const action = mixer.clipAction(clip);
action.play(); // start playing
action.paused = true; // pause
action.paused = false; // resume
action.stop(); // stop and reset
action.time = 1.5; // jump to a specific second
// Speed
action.timeScale = 2; // double speed
action.timeScale = 0.5; // half speed
action.timeScale = -1; // play in reverse!
// Looping
action.loop = THREE.LoopRepeat; // loop forever (default)
action.loop = THREE.LoopOnce; // play once, stop on last frame
action.loop = THREE.LoopPingPong; // bounce back and forth
action.repetitions = 3; // loop 3 times
// Hold the last pose when done
action.clampWhenFinished = true;
blending between animations
For characters, you often want to smoothly transition from one animation to another — like going from idle to walk without a sudden snap.
// Assume we have "idle" and "walk" clips
const idleAction = mixer.clipAction(idleClip);
const walkAction = mixer.clipAction(walkClip);
// Start both, but idle is fully visible and walk is invisible
idleAction.play();
walkAction.play();
walkAction.weight = 0; // invisible
// When the player starts moving, fade walk in over 0.3 seconds
function startWalking() {
walkAction.reset().fadeIn(0.3);
idleAction.fadeOut(0.3);
}
// When they stop
function stopWalking() {
walkAction.fadeOut(0.3);
idleAction.reset().fadeIn(0.3);
}
The weight property (0 to 1) controls how much each action contributes to the final pose. At weight = 0 the action is invisible. At weight = 1 it's fully applied.
listening for when animations finish
mixer.addEventListener('finished', (event) => {
console.log('Done playing:', event.action.getClip().name);
// Great for triggering something after a "wave" animation completes
});
mixer.addEventListener('loop', (event) => {
console.log('Looped:', event.action.getClip().name);
});
morph targets — face expressions and shape blending
Morph targets (also called "blend shapes") let you smoothly deform a model from one shape to another. If you've ever seen a 3D character smile, raise an eyebrow, or open their mouth — that's morph targets at work.
how they work
A morph target is just a "copy" of the original geometry with some vertices moved. You control how much each target influences the final shape with a number between 0 and 1.
Original Smile (target 1) Open Mouth (target 2)
🙂 😊 😮
influence: 0 influence: 0.7 influence: 0.3
checking if a model has morph targets
Load a model, then inspect its geometry:
loader.load('/models/head.glb', (gltf) => {
const head = gltf.scene.children[0];
// Check what morph target names exist
console.log(head.morphTargetNames);
// e.g. ["smile", "frown", "eyebrowRaise", "mouthOpen", "blink", ...]
// Check the geometry data
const geometry = head.geometry;
console.log(geometry.morphAttributes);
// { position: [target0, target1, ...] }
});
controlling morphs
You control morphs by setting values in morphTargetInfluences — an array of numbers (0–1), one per target.
// Morph target order matches morphTargetNames
// 0: "smile", 1: "frown", 2: "eyebrowRaise", 3: "mouthOpen"
// Big smile
head.morphTargetInfluences[0] = 1; // smile: 100%
head.morphTargetInfluences[1] = 0; // frown: 0%
// Surprised — eyebrows up + mouth open
head.morphTargetInfluences[2] = 0.8; // eyebrows: 80%
head.morphTargetInfluences[3] = 0.9; // mouth: 90%
animating morphs
You can animate them in the render loop or with GSAP:
With the render loop:
function animate() {
const elapsed = clock.getElapsedTime();
// Blink every 3 seconds (assuming index 4 is "blink")
const blinkCycle = (elapsed % 3) / 3;
head.morphTargetInfluences[4] =
blinkCycle 0.1 ? Math.sin((blinkCycle / 0.1) * Math.PI) : 0;
// Subtle talking motion (index 3 is "mouthOpen")
head.morphTargetInfluences[3] =
Math.sin(elapsed * 8) * 0.5 + 0.5;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
With GSAP (from Animate post):
import gsap from 'gsap';
// Smooth smile transition over 0.5 seconds
gsap.to(head.morphTargetInfluences, {
0: 1, // smile to 100%
1: 0, // frown to 0%
duration: 0.5,
ease: 'power2.out',
});
The morph target indices are just plain numbers on an array — and GSAP can tween any array of numbers. It just works.
making your own morph targets
You can also create morph targets directly in code:
const geometry = new THREE.BoxGeometry(1, 1, 1);
const positionAttribute = geometry.attributes.position;
// Create a morph target — move the top vertices up
const morphPositions = positionAttribute.clone();
const positions = morphPositions.array;
for (let i = 0; i positions.length; i += 3) {
if (positions[i + 1] > 0) { // y > 0 — top vertices
positions[i + 1] += 0.5; // move them up
}
}
geometry.morphAttributes.position = [morphPositions];
geometry.morphTargetsRelative = true; // values are offsets, not absolute
const material = new THREE.MeshStandardMaterial({
morphTargets: true, // you MUST enable this
});
const mesh = new THREE.Mesh(geometry, material);
mesh.morphTargetInfluences[0] = 0.5; // 50% deformed
which approach should you use?
| You want to... | Use this |
|---|---|
| Make a cube spin or bounce | Render loop + delta |
| Float an object up and down | Render loop + Math.sin(elapsed) |
| Play a GLTF model's built-in animation | AnimationMixer + clipAction() |
| Blend between walk/run/idle smoothly | Mixer with fadeIn/fadeOut / weight |
| Make a character smile or blink | Morph targets |
| Animate a custom property (like a slider value) | Render loop or GSAP |
| Sequence multiple animations one after another | GSAP timeline (see Animate) |
a few tips to remember
- Always call
mixer.update(delta)in your render loop — if your model's animation isn't playing, this is probably why. - Clean up mixers when you remove a model — call
mixer.stopAllAction()and let the mixer be garbage collected. Otherwise animations keep running invisibly. - Morph performance — morph targets are evaluated on the GPU, so they're fast. But having 100+ morph targets on one mesh can get heavy. Most character models use 10–30.
- GLTF animations are usually in seconds — if a clip seems too fast or slow, adjust
action.timeScale. - You can combine approaches — use the render loop for idle floating, and the mixer for triggered animations like waving. They don't conflict.
why this matters
The render loop is the foundation — it's where every Three.js app starts. AnimationMixer is essential as soon as you bring in models from Blender, Sketchfab, or anywhere else. And morph targets are what make characters feel alive.
Once you're comfortable with all three, you can create scenes that don't just sit there — they breathe, move, and react.
