| name | threejs-animation |
| description | Three.js animation - keyframe animation, skeletal animation, morph targets, animation mixing. Use when animating objects, playing GLTF animations, creating procedural motion, or blending animations. |
| risk | unknown |
| source | community |
Three.js Animation
Quick Start
import * as THREE from "three";
const timer = new THREE.Timer();
renderer.setAnimationLoop(() => {
timer.update();
const delta = timer.getDelta();
const elapsed = timer.getElapsed();
mesh.rotation.y += delta;
mesh.position.y = Math.sin(elapsed) * 0.5;
renderer.render(scene, camera);
});
Note: THREE.Timer is recommended over THREE.Clock as of r183. Timer pauses when the page is hidden and has a cleaner API. THREE.Clock still works but is considered legacy.
Animation System Overview
Three.js animation system has three main components:
- AnimationClip - Container for keyframe data
- AnimationMixer - Plays animations on a root object
- AnimationAction - Controls playback of a clip
AnimationClip
Stores keyframe animation data.
const times = [0, 1, 2];
const values = [0, 1, 0];
const track = new THREE.NumberKeyframeTrack(
".position[y]",
times,
values,
);
const clip = new THREE.AnimationClip("bounce", 2, [track]);
KeyframeTrack Types
new THREE.NumberKeyframeTrack(".opacity", times, [1, 0]);
new THREE.NumberKeyframeTrack(".material.opacity", times, [1, 0]);
new THREE.VectorKeyframeTrack(".position", times, [
0,
0,
0,
1,
2,
0,
0,
0,
0,
]);
const q1 = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, 0));
const q2 = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, Math.PI, 0));
new THREE.QuaternionKeyframeTrack(
".quaternion",
[0, 1],
[q1.x, q1.y, q1.z, q1.w, q2.x, q2.y, q2.z, q2.w],
);
new THREE.ColorKeyframeTrack(".material.color", times, [
1,
0,
0,
0,
1,
0,
0,
0,
1,
]);
new THREE.BooleanKeyframeTrack(".visible", [0, 0.5, 1], [true, false, true]);
new THREE.StringKeyframeTrack(
".morphTargetInfluences[smile]",
[0, 1],
["0", "1"],
);
Interpolation Modes
const track = new THREE.VectorKeyframeTrack(".position", times, values);
track.setInterpolation(THREE.InterpolateLinear);
track.setInterpolation(THREE.InterpolateSmooth);
track.setInterpolation(THREE.InterpolateDiscrete);
BezierInterpolant (r183)
Three.js r183 adds THREE.BezierInterpolant for bezier curve interpolation in keyframe tracks, enabling smoother animation curves with tangent control.
AnimationMixer
Plays animations on an object and its descendants.
const mixer = new THREE.AnimationMixer(model);
const action = mixer.clipAction(clip);
action.play();
function animate() {
const delta = clock.getDelta();
mixer.update(delta);
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
Mixer Events
mixer.addEventListener("finished", (e) => {
console.log("Animation finished:", e.action.getClip().name);
});
mixer.addEventListener("loop", (e) => {
console.log("Animation looped:", e.action.getClip().name);
});
AnimationAction
Controls playback of an animation clip.
const action = mixer.clipAction(clip);
action.play();
action.stop();
action.reset();
action.halt(fadeOutDuration);
action.isRunning();
action.isScheduled();
action.time = 0.5;
action.timeScale = 1;
action.paused = false;
action.weight = 1;
action.setEffectiveWeight(1);
action.loop = THREE.LoopRepeat;
action.loop = THREE.LoopOnce;
action.loop = THREE.LoopPingPong;
action.repetitions = 3;
action.clampWhenFinished = true;
action.blendMode = THREE.NormalAnimationBlendMode;
action.blendMode = THREE.AdditiveAnimationBlendMode;
Fade In/Out
action.reset().fadeIn(0.5).play();
action.fadeOut(0.5);
const action1 = mixer.clipAction(clip1);
const action2 = mixer.clipAction(clip2);
action1.play();
action1.crossFadeTo(action2, 0.5, true);
action2.play();
Loading GLTF Animations
Most common source of skeletal animations.
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
const loader = new GLTFLoader();
loader.load("model.glb", (gltf) => {
const model = gltf.scene;
scene.add(model);
const mixer = new THREE.AnimationMixer(model);
const clips = gltf.animations;
console.log(
"Available animations:",
clips.map((c) => c.name),
);
if (clips.length > 0) {
const action = mixer.clipAction(clips[0]);
action.play();
}
const walkClip = THREE.AnimationClip.findByName(clips, "Walk");
if (walkClip) {
mixer.clipAction(walkClip).play();
}
window.mixer = mixer;
});
function animate() {
const delta = clock.getDelta();
if (window.mixer) window.mixer.update(delta);
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
Skeletal Animation
Skeleton and Bones
const skinnedMesh = model.getObjectByProperty("type", "SkinnedMesh");
const skeleton = skinnedMesh.skeleton;
skeleton.bones.forEach((bone) => {
console.log(bone.name, bone.position, bone.rotation);
});
const headBone = skeleton.bones.find((b) => b.name === "Head");
if (headBone) headBone.rotation.y = Math.PI / 4;
const helper = new THREE.SkeletonHelper(model);
scene.add(helper);
Programmatic Bone Animation
function animate() {
const time = clock.getElapsedTime();
const headBone = skeleton.bones.find((b) => b.name === "Head");
if (headBone) {
headBone.rotation.y = Math.sin(time) * 0.3;
}
mixer.update(clock.getDelta());
}
Bone Attachments
const weapon = new THREE.Mesh(weaponGeometry, weaponMaterial);
const handBone = skeleton.bones.find((b) => b.name === "RightHand");
if (handBone) handBone.add(weapon);
weapon.position.set(0, 0, 0.5);
weapon.rotation.set(0, Math.PI / 2, 0);
Morph Targets
Blend between different mesh shapes.
const geometry = mesh.geometry;
console.log("Morph attributes:", Object.keys(geometry.morphAttributes));
mesh.morphTargetInfluences;
mesh.morphTargetDictionary;
mesh.morphTargetInfluences[0] = 0.5;
const smileIndex = mesh.morphTargetDictionary["smile"];
mesh.morphTargetInfluences[smileIndex] = 1;
Animating Morph Targets
function animate() {
const t = clock.getElapsedTime();
mesh.morphTargetInfluences[0] = (Math.sin(t) + 1) / 2;
}
const track = new THREE.NumberKeyframeTrack(
".morphTargetInfluences[smile]",
[0, 0.5, 1],
[0, 1, 0],
);
const clip = new THREE.AnimationClip("smile", 1, [track]);
mixer.clipAction(clip).play();
Animation Blending
Mix multiple animations together.
const idleAction = mixer.clipAction(idleClip);
const walkAction = mixer.clipAction(walkClip);
const runAction = mixer.clipAction(runClip);
idleAction.play();
walkAction.play();
runAction.play();
idleAction.setEffectiveWeight(1);
walkAction.setEffectiveWeight(0);
runAction.setEffectiveWeight(0);
function updateAnimations(speed) {
if (speed < 0.1) {
idleAction.setEffectiveWeight(1);
walkAction.setEffectiveWeight(0);
runAction.setEffectiveWeight(0);
} else if (speed < 5) {
const t = speed / 5;
idleAction.setEffectiveWeight(1 - t);
walkAction.setEffectiveWeight(t);
runAction.setEffectiveWeight(0);
} else {
const t = Math.min((speed - 5) / 5, 1);
idleAction.setEffectiveWeight(0);
walkAction.setEffectiveWeight(1 - t);
runAction.setEffectiveWeight(t);
}
}
Additive Blending
const baseAction = mixer.clipAction(baseClip);
baseAction.play();
const additiveAction = mixer.clipAction(additiveClip);
additiveAction.blendMode = THREE.AdditiveAnimationBlendMode;
additiveAction.play();
THREE.AnimationUtils.makeClipAdditive(additiveClip);
Animation Utilities
import * as THREE from "three";
const clip = THREE.AnimationClip.findByName(clips, "Walk");
const subclip = THREE.AnimationUtils.subclip(clip, "subclip", 0, 30, 30);
THREE.AnimationUtils.makeClipAdditive(clip);
THREE.AnimationUtils.makeClipAdditive(clip, 0, referenceClip);
const clone = clip.clone();
clip.duration;
clip.optimize();
clip.resetDuration();
Procedural Animation Patterns
Smooth Damping
const target = new THREE.Vector3();
const current = new THREE.Vector3();
const velocity = new THREE.Vector3();
function smoothDamp(current, target, velocity, smoothTime, deltaTime) {
const omega = 2 / smoothTime;
const x = omega * deltaTime;
const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
const change = current.clone().sub(target);
const temp = velocity
.clone()
.add(change.clone().multiplyScalar(omega))
.multiplyScalar(deltaTime);
velocity.sub(temp.clone().multiplyScalar(omega)).multiplyScalar(exp);
return target.clone().add(change.add(temp).multiplyScalar(exp));
}
function animate() {
current.copy(smoothDamp(current, target, velocity, 0.3, delta));
mesh.position.copy(current);
}
Spring Physics
class Spring {
constructor(stiffness = 100, damping = 10) {
this.stiffness = stiffness;
this.damping = damping;
this.position = 0;
this.velocity = 0;
this.target = 0;
}
update(dt) {
const force = -this.stiffness * (this.position - this.target);
const dampingForce = -this.damping * this.velocity;
this.velocity += (force + dampingForce) * dt;
this.position += this.velocity * dt;
return this.position;
}
}
const spring = new Spring(100, 10);
spring.target = 1;
function animate() {
mesh.position.y = spring.update(delta);
}
Oscillation
function animate() {
const t = clock.getElapsedTime();
mesh.position.y = Math.sin(t * 2) * 0.5;
mesh.position.y = Math.abs(Math.sin(t * 3)) * 2;
mesh.position.x = Math.cos(t) * 2;
mesh.position.z = Math.sin(t) * 2;
mesh.position.x = Math.sin(t) * 2;
mesh.position.z = Math.sin(t * 2) * 1;
}
Performance Tips
- Share clips: Same AnimationClip can be used on multiple mixers
- Optimize clips: Call
clip.optimize() to remove redundant keyframes
- Disable when off-screen: Stop mixer updates for invisible objects
- Use LOD for animations: Simpler rigs for distant characters
- Limit active mixers: Each mixer.update() has a cost
mesh.onBeforeRender = () => {
action.paused = false;
};
mesh.onAfterRender = () => {
if (!isInFrustum(mesh)) {
action.paused = true;
}
};
const clipCache = new Map();
function getClip(name) {
if (!clipCache.has(name)) {
clipCache.set(name, loadClip(name));
}
return clipCache.get(name);
}
See Also
threejs-loaders - Loading animated GLTF models
threejs-fundamentals - Clock and animation loop
threejs-shaders - Vertex animation in shaders
Rant-capture at handback
After completing the work, before returning to the user, ask:
"Returned to bench. Anything here you'd rant about?"
If the user responds, append the entry to the current project's .orca/design-rants-pending.md in this format:
## YYYY-MM-DD HH:MM — [verb-name]
[user's response verbatim]
Create .orca/ in the current project if absent. Do NOT write to ~/.claude/ or to the ORCA-OS source tree directly. Pending entries are swept and categorized later via /impeccable extract rants.