Use pmndrs/timeline in vanilla three.js

Drive three.js scenes without React using @pmndrs/timeline.

@pmndrs/timeline works in any three.js app — no React required. You create a timeline, start it, and call the returned update function each frame.

Differences to React usage

  • In React you use useTimeline(...) which ties into the render loop for you.
  • In vanilla you call start(timeline) and invoke the returned function every frame with deltaSeconds (the library internally clamps large deltas to ~1/30s for stability).

Build a scene with @pmndrs/timeline

Below is a complete example using two spheres and a camera that alternates between them.

import { action, lookAt, spring, springPresets, start, timePassed } from '@pmndrs/timeline'
import { EffectComposer, RenderPass, EffectPass, BloomEffect, VignetteEffect } from 'postprocessing'
import { PerspectiveCamera, Scene, WebGLRenderer, Color, Mesh, MeshPhysicalMaterial, SphereGeometry, ACESFilmicToneMapping, AmbientLight, SRGBColorSpace, PMREMGenerator, HalfFloatType, MeshStandardMaterial } from 'three'
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js'
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js'

const root = document.getElementById('root') as HTMLDivElement

// renderer
const renderer = new WebGLRenderer()
renderer.outputColorSpace = SRGBColorSpace
renderer.toneMapping = ACESFilmicToneMapping
renderer.toneMappingExposure = 1
root.appendChild(renderer.domElement)

// scene & camera
const scene = new Scene()
scene.background = new Color(0x0a0a0a)
const camera = new PerspectiveCamera(80, 1, 0.1, 100)
camera.position.set(0, 0, 5)
scene.add(camera)

// postprocessing
const composer = new EffectComposer(renderer, { frameBufferType: HalfFloatType })
composer.addPass(new RenderPass(scene, camera))
const bloom = new BloomEffect({ luminanceThreshold: 2, luminanceSmoothing: 0.9, height: 300 })
const vignette = new VignetteEffect({ eskil: false, offset: 0.1, darkness: 1.1 })
composer.addPass(new EffectPass(camera, bloom, vignette))

// lights
scene.add(new AmbientLight(0xffffff, 0.2))

// geometry & meshes
const sphere = new SphereGeometry(1, 32, 32)
const redPill = new Mesh(sphere, new MeshPhysicalMaterial({ color: 'red', emissive: 'red', emissiveIntensity: 9 }))
redPill.position.set(-2, -1, 0)
redPill.scale.set(0.2, 0.2, 0.4)
redPill.rotation.y = (-30 / 180) * Math.PI
scene.add(redPill)

const bluePill = new Mesh(sphere, new MeshPhysicalMaterial({ color: 'blue', emissive: 'blue', emissiveIntensity: 30 }))
bluePill.position.set(2, -1, 0)
bluePill.scale.set(0.2, 0.2, 0.4)
bluePill.rotation.y = (20 / 180) * Math.PI
scene.add(bluePill)

// text
const fontLoader = new FontLoader()
fontLoader.load(
'https://threejs.org/examples/fonts/helvetiker_regular.typeface.json',
(font) => {
  const textGeometry = new TextGeometry("Remember: all I'm offering is the truth. Nothing more.", { font, size: 0.3, depth: 0.02, curveSegments: 8 })
  const textMaterial = new MeshStandardMaterial({ color: 0xcccccc, toneMapped: false })
  const text = new Mesh(textGeometry, textMaterial)
  text.position.set(-4.5, 0.5, 0)
  scene.add(text)
}
)

// environment
const url = 'https://raw.githubusercontent.com/pmndrs/drei-assets/456060a26bbeb8fdf79326f224b6d99b8bcce736/hdri/studio_small_03_1k.hdr'
const rgbe = new RGBELoader()
rgbe.load(url, (hdr) => {
const pmrem = new PMREMGenerator(renderer)
pmrem.compileEquirectangularShader()
const env = pmrem.fromEquirectangular(hdr)
scene.environment = env.texture
scene.background = env.texture
scene.backgroundRotation.y = (90 / 180) * Math.PI
scene.backgroundBlurriness = 0.1
scene.backgroundIntensity = 0.1
hdr.dispose()
pmrem.dispose()
})

// timeline
async function* mainTimeline() {
while (true) {
  yield* action({ update: lookAt(camera, redPill, spring(springPresets.stiff)) })
  yield* action({ until: timePassed(0.3, 'seconds') })
  yield* action({ update: lookAt(camera, bluePill, spring(springPresets.stiff)) })
  yield* action({ until: timePassed(0.3, 'seconds') })
}
}
const update = start(mainTimeline())

// render loop
let last = performance.now()
function onFrame(now: number) {
const delta = Math.max(1e-6, Math.min(1/30, Math.max(0, (now - last) / 1000)))
last = now
update(undefined, delta)
composer.render(delta)
requestAnimationFrame(onFrame)
}
requestAnimationFrame(onFrame)

// resize
function onResize() {
const width = root.clientWidth
const height = root.clientHeight
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio)
composer.setSize(width, height)
}
onResize()
window.addEventListener('resize', onResize)

The first step is to install the dependencies.

pnpm add three @pmndrs/timeline

Next, we create the index.ts file and import the necessary dependencies and setup a three.js scene.

  1. Create a renderer, scene, and camera
const root = document.getElementById('root') as HTMLDivElement
const renderer = new WebGLRenderer()
renderer.outputColorSpace = SRGBColorSpace
renderer.toneMapping = ACESFilmicToneMapping
renderer.toneMappingExposure = 1
root.appendChild(renderer.domElement)

const scene = new Scene()
const camera = new PerspectiveCamera(80, 1, 0.1, 100)
camera.position.set(0, 0, 5)
scene.add(camera)
  1. Optional: add postprocessing and lights
const composer = new EffectComposer(renderer, { frameBufferType: HalfFloatType })
composer.addPass(new RenderPass(scene, camera))
const bloom = new BloomEffect({ luminanceThreshold: 2, luminanceSmoothing: 0.9, height: 300 })
const vignette = new VignetteEffect({ eskil: false, offset: 0.1, darkness: 1.1 })
composer.addPass(new EffectPass(camera, bloom, vignette))
scene.add(new AmbientLight(0xffffff, 0.2))
  1. Place meshes (targets for the camera)
const sphere = new SphereGeometry(1, 32, 32)
const redPill = new Mesh(sphere, new MeshPhysicalMaterial({ color: 'red', emissive: 'red', emissiveIntensity: 9 }))
redPill.position.set(-2, -1, 0)
redPill.scale.set(0.2, 0.2, 0.4)
redPill.rotation.y = (-30 / 180) * Math.PI
scene.add(redPill)

const bluePill = new Mesh(sphere, new MeshPhysicalMaterial({ color: 'blue', emissive: 'blue', emissiveIntensity: 30 }))
bluePill.position.set(2, -1, 0)
bluePill.scale.set(0.2, 0.2, 0.4)
bluePill.rotation.y = (20 / 180) * Math.PI
scene.add(bluePill)
  1. Create and start the timeline
async function* mainTimeline() {
  while (true) {
    yield* action({ update: lookAt(camera, redPill, spring(springPresets.stiff)) })
    yield* action({ until: timePassed(0.3, 'seconds') })
    yield* action({ update: lookAt(camera, bluePill, spring(springPresets.stiff)) })
    yield* action({ until: timePassed(0.3, 'seconds') })
  }
}
const update = start(mainTimeline())
  1. Drive frames and handle resize
let last = performance.now()
function onFrame(now: number) {
  const delta = Math.max(1e-6, Math.min(1/30, Math.max(0, (now - last) / 1000)))
  last = now
  update(undefined, delta)
  composer.render(delta)
  requestAnimationFrame(onFrame)
}
requestAnimationFrame(onFrame)

function onResize() {
  const width = root.clientWidth
  const height = root.clientHeight
  camera.aspect = width / height
  camera.updateProjectionMatrix()
  renderer.setSize(width, height)
  renderer.setPixelRatio(window.devicePixelRatio)
  composer.setSize(width, height)
}
onResize()
window.addEventListener('resize', onResize)
  1. Optional: add text and an HDRI environment
const font = await new FontLoader().loadAsync('https://threejs.org/examples/fonts/helvetiker_regular.typeface.json')
const textGeometry = new TextGeometry("Remember: all I'm offering is the truth. Nothing more.", { font, size: 0.3, depth: 0.02, curveSegments: 8 })
const text = new Mesh(textGeometry, new MeshStandardMaterial({ color: 0xcccccc, toneMapped: false }))
text.position.set(-4.5, 0.5, 0)
scene.add(text)

const hdr = await new RGBELoader().loadAsync('https://raw.githubusercontent.com/pmndrs/drei-assets/456060a26bbeb8fdf79326f224b6d99b8bcce736/hdri/studio_small_03_1k.hdr')
const pmrem = new PMREMGenerator(renderer)
pmrem.compileEquirectangularShader()
const env = pmrem.fromEquirectangular(hdr)
scene.environment = env.texture
scene.background = env.texture
scene.backgroundRotation.y = (90 / 180) * Math.PI
scene.backgroundBlurriness = 0.1
scene.backgroundIntensity = 0.1
hdr.dispose(); pmrem.dispose()

If you use vite (pnpm add vite), you can create a index.html file, add the following content, and run pnpm vite.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script type="module" src="index.ts"></script>
  </head>
  <body style="margin: 0;">
    <div id="root" style="position:absolute; inset: 0; touch-action: none"></div>
  </body>
</html>