Three.js animation - keyframe animation, skeletal animation, morph targets, animation mixing. Use when animating objects, playing GLTF animations, creating procedural motion, or blending animations.
import * as THREE from 'three'
// Simple procedural animation
const clock = new THREE.Clock()
function animate() {
const delta = clock.getDelta()
const elapsed = clock.getElapsedTime()
mesh.rotation.y += delta
mesh.position.y = Math.sin(elapsed) * 0.5
requestAnimationFrame(animate)
renderer.render(scene, camera)
}
animate()
Three.js animation system has three main components:
Stores keyframe animation data.
// Create animation clip
const times = [0, 1, 2] // Keyframe times (seconds)
const values = [0, 1, 0] // Values at each keyframe
const track = new THREE.NumberKeyframeTrack(
'.position[y]', // Property path
times,
values,
)
const clip = new THREE.AnimationClip('bounce', 2, [track])
// Number track (single value)
new THREE.NumberKeyframeTrack('.opacity', times, [1, 0])
new THREE.NumberKeyframeTrack('.material.opacity', times, [1, 0])
// Vector track (position, scale)
new THREE.VectorKeyframeTrack('.position', times, [
0,
0,
0, // t=0
1,
2,
0, // t=1
0,
0,
0, // t=2
])
// Quaternion track (rotation)
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],
)
// Color track
new THREE.ColorKeyframeTrack('.material.color', times, [
1,
0,
0, // red
0,
1,
0, // green
0,
0,
1, // blue
])
// Boolean track
new THREE.BooleanKeyframeTrack('.visible', [0, 0.5, 1], [true, false, true])
// String track (for morph targets)
new THREE.StringKeyframeTrack(
'.morphTargetInfluences[smile]',
[0, 1],
['0', '1'],
)
const track = new THREE.VectorKeyframeTrack('.position', times, values)
// Interpolation
track.setInterpolation(THREE.InterpolateLinear) // Default
track.setInterpolation(THREE.InterpolateSmooth) // Cubic spline
track.setInterpolation(THREE.InterpolateDiscrete) // Step function
Plays animations on an object and its descendants.
const mixer = new THREE.AnimationMixer(model)
// Create action from clip
const action = mixer.clipAction(clip)
action.play()
// Update in animation loop
function animate() {
const delta = clock.getDelta()
mixer.update(delta) // Required!
requestAnimationFrame(animate)
renderer.render(scene, camera)
}
mixer.addEventListener('finished', (e) => {
console.log('Animation finished:', e.action.getClip().name)
})
mixer.addEventListener('loop', (e) => {
console.log('Animation looped:', e.action.getClip().name)
})
Controls playback of an animation clip.
const action = mixer.clipAction(clip)
// Playback control
action.play()
action.stop()
action.reset()
action.halt(fadeOutDuration)
// Playback state
action.isRunning()
action.isScheduled()
// Time control
action.time = 0.5 // Current time
action.timeScale = 1 // Playback speed (negative = reverse)
action.paused = false
// Weight (for blending)
action.weight = 1 // 0-1, contribution to final pose
action.setEffectiveWeight(1)
// Loop modes
action.loop = THREE.LoopRepeat // Default: loop forever
action.loop = THREE.LoopOnce // Play once and stop
action.loop = THREE.LoopPingPong // Alternate forward/backward
action.repetitions = 3 // Number of loops (Infinity default)
// Clamping
action.clampWhenFinished = true // Hold last frame when done
// Blending
action.blendMode = THREE.NormalAnimationBlendMode
action.blendMode = THREE.AdditiveAnimationBlendMode
// Fade in
action.reset().fadeIn(0.5).play()
// Fade out
action.fadeOut(0.5)
// Crossfade between animations
const action1 = mixer.clipAction(clip1)
const action2 = mixer.clipAction(clip2)
action1.play()
// Later, crossfade to action2
action1.crossFadeTo(action2, 0.5, true)
action2.play()
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)
// Create mixer
const mixer = new THREE.AnimationMixer(model)
// Get all clips
const clips = gltf.animations
console.log(
'Available animations:',
clips.map((c) => c.name),
)
// Play first animation
if (clips.length > 0) {
const action = mixer.clipAction(clips[0])
action.play()
}
// Play specific animation by name
const walkClip = THREE.AnimationClip.findByName(clips, 'Walk')
if (walkClip) {
mixer.clipAction(walkClip).play()
}
// Store mixer for update loop
window.mixer = mixer
})
// Animation loop
function animate() {
const delta = clock.getDelta()
if (window.mixer) window.mixer.update(delta)
requestAnimationFrame(animate)
renderer.render(scene, camera)
}
// Access skeleton from skinned mesh
const skinnedMesh = model.getObjectByProperty('type', 'SkinnedMesh')
const skeleton = skinnedMesh.skeleton
// Access bones
skeleton.bones.forEach((bone) => {
console.log(bone.name, bone.position, bone.rotation)
})
// Find specific bone by name
const headBone = skeleton.bones.find((b) => b.name === 'Head')
if (headBone) headBone.rotation.y = Math.PI / 4 // Turn head
// Skeleton helper
const helper = new THREE.SkeletonHelper(model)
scene.add(helper)
function animate() {
const time = clock.getElapsedTime()
// Animate bone
const headBone = skeleton.bones.find((b) => b.name === 'Head')
if (headBone) {
headBone.rotation.y = Math.sin(time) * 0.3
}
// Update mixer if also playing clips
mixer.update(clock.getDelta())
}
// Attach object to bone
const weapon = new THREE.Mesh(weaponGeometry, weaponMaterial)
const handBone = skeleton.bones.find((b) => b.name === 'RightHand')
if (handBone) handBone.add(weapon)
// Offset attachment
weapon.position.set(0, 0, 0.5)
weapon.rotation.set(0, Math.PI / 2, 0)
Blend between different mesh shapes.
// Morph targets are stored in geometry
const geometry = mesh.geometry
console.log('Morph attributes:', Object.keys(geometry.morphAttributes))
// Access morph target influences
mesh.morphTargetInfluences // Array of weights
mesh.morphTargetDictionary // Name -> index mapping
// Set morph target by index
mesh.morphTargetInfluences[0] = 0.5
// Set by name
const smileIndex = mesh.morphTargetDictionary['smile']
mesh.morphTargetInfluences[smileIndex] = 1
// Procedural
function animate() {
const t = clock.getElapsedTime()
mesh.morphTargetInfluences[0] = (Math.sin(t) + 1) / 2
}
// With keyframe animation
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()
Mix multiple animations together.
// Setup actions
const idleAction = mixer.clipAction(idleClip)
const walkAction = mixer.clipAction(walkClip)
const runAction = mixer.clipAction(runClip)
// Play all with different weights
idleAction.play()
walkAction.play()
runAction.play()
// Set initial weights
idleAction.setEffectiveWeight(1)
walkAction.setEffectiveWeight(0)
runAction.setEffectiveWeight(0)
// Blend based on speed
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)
}
}
// Base pose
const baseAction = mixer.clipAction(baseClip)
baseAction.play()
// Additive layer (e.g., breathing)
const additiveAction = mixer.clipAction(additiveClip)
additiveAction.blendMode = THREE.AdditiveAnimationBlendMode
additiveAction.play()
// Convert clip to additive
THREE.AnimationUtils.makeClipAdditive(additiveClip)
import * as THREE from 'three'
// Find clip by name
const clip = THREE.AnimationClip.findByName(clips, 'Walk')
// Create subclip
const subclip = THREE.AnimationUtils.subclip(clip, 'subclip', 0, 30, 30)
// Convert to additive
THREE.AnimationUtils.makeClipAdditive(clip)
THREE.AnimationUtils.makeClipAdditive(clip, 0, referenceClip)
// Clone clip
const clone = clip.clone()
// Get clip duration
clip.duration
// Optimize clip (remove redundant keyframes)
clip.optimize()
// Reset clip to first frame
clip.resetDuration()
// Smooth follow/lerp
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)
}
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)
}
function animate() {
const t = clock.getElapsedTime()
// Sine wave
mesh.position.y = Math.sin(t * 2) * 0.5
// Bouncing
mesh.position.y = Math.abs(Math.sin(t * 3)) * 2
// Circular motion
mesh.position.x = Math.cos(t) * 2
mesh.position.z = Math.sin(t) * 2
// Figure 8
mesh.position.x = Math.sin(t) * 2
mesh.position.z = Math.sin(t * 2) * 1
}
clip.optimize() to remove redundant keyframes// Pause animation when not visible
mesh.onBeforeRender = () => {
action.paused = false
}
mesh.onAfterRender = () => {
// Check if will be visible next frame
if (!isInFrustum(mesh)) {
action.paused = true
}
}
// Cache clips
const clipCache = new Map()
function getClip(name) {
if (!clipCache.has(name)) {
clipCache.set(name, loadClip(name))
}
return clipCache.get(name)
}
threejs-loaders - Loading animated GLTF modelsthreejs-fundamentals - Clock and animation loopthreejs-shaders - Vertex animation in shaders