---
date: 2026-7-6
---

<!-- markdownlint-disable MD001 -->

# Animation Systems

![threejs animation](https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExZWgwajRpZzQ0MTNsbGFodzg0bGdvMWNoOGY3bXA1ZnM5ZDY1ZzMybCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/GuLV5GXVCyBaxduqGl/giphy.gif)

## three.js animations

The [Animate](/threejs-blog/11) 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.

```javascript
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.

```javascript
// ❌ 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:

```javascript
// 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](https://sketchfab.com) or [Poly Pizza](https://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:

```javascript
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:

```javascript
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:

```javascript
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.

```javascript
// 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

```javascript
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.

```text
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:

```javascript
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.

```javascript
// 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:**

```javascript
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](/threejs-blog/11)):**

```javascript
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:

```javascript
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](/threejs-blog/11)) |

### 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.
