Animation Systems

threejs animation

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.