Navigate

Search topics across all sections

GitHub
Scene Basics

Animation Loop

A 3D scene is not a static picture. It redraws itself many times per second, and each redraw is your chance to move things, spin things, or react to user input. This is the animation loop — the heartbeat of every 3D experience.

You test your spinning cube on your laptop and it looks perfect — a smooth, gentle rotation. Your friend opens it on their gaming monitor and the cube is spinning twice as fast. Another friend on an old tablet sees it moving in slow motion. Same code, three different speeds.

terminal
Animation speed varies across devices. Cube rotates 2.4x faster on 144Hz vs 60Hz.

Real-world

Remember those flipbooks you made as a kid? You draw a slightly different picture on each page, then flip through them fast and it looks like smooth animation.

Your computer does the same thing — it draws about 60 "pages" per second. Each page is called a frame.

On each frame, you get a chance to change things: rotate an object a tiny bit, move a character slightly forward, update a color. Flip through all these tiny changes fast enough, and you get smooth motion.

But here's the catch: some flipbooks have 60 pages per second, others have 144. If you move an object "1 step per page", faster flipbooks mean faster movement. That's why we need delta time.

Frame Starts

Browser says: time to draw!

Update

Move objects, apply physics

Render

Draw the new picture

Repeat

~60 times per second

Hands-On: Making Things Move

In R3F, the useFrame hook is your animation loop. It runs once per frame and gives you everything you need to create smooth animations.

Step 1: The simplest animation

SpinningBox.tsxTSX
function SpinningBox() {
  const ref = useRef<Mesh>(null!);

  useFrame(() => {
    ref.current.rotation.y += 0.01;
  });

  return (
    <mesh ref={ref}>
      <boxGeometry />
      <meshStandardMaterial color="tomato" />
    </mesh>
  );
}

useFrame runs your callback every single frame. Here, we add 0.01 radians to the Y rotation each frame, making the cube spin. But there's a problem — this spins faster on a 144Hz monitor than on a 60Hz one.

Step 2: Fix it with delta time

SpinningBox.tsxTSX
useFrame((state, delta) => {
  // 1 radian per second, on ANY device
  ref.current.rotation.y += 1.0 * delta;
});

The second argument, delta, is the time in seconds since the last frame. On a 60Hz monitor, delta is ~0.016s. On 144Hz, it's ~0.007s. Multiply your speed by delta, and the rotation is always 1 radian per second regardless of frame rate. Problem solved!

Step 3: Use the clock for oscillation

BobbingBox.tsxTSX
useFrame((state) => {
  const t = state.clock.elapsedTime;
  ref.current.position.y = Math.sin(t) * 0.5;
  // Bobs up and down smoothly forever
});

state.clock.elapsedTime gives you the total seconds since the scene started. Feed it into Math.sin() and you get a smooth wave that oscillates between -1 and 1. Multiply by 0.5 to bob half a unit up and down. Try changing 0.5 to 2 for a bigger bounce!

What you just learned

The animation loop (useFrame) runs once per frame — about 60 times per second — and is where you update positions, rotations, and other properties.

Always multiply speeds by 'delta' (time since last frame) to make animations look the same on 60Hz and 144Hz monitors.

state.clock.elapsedTime gives the total time since the scene started — perfect for smooth oscillations with Math.sin().

Never create new objects (new Vector3, new Color) inside useFrame — pre-allocate them outside to avoid memory issues.

Question

If the animation loop runs 60 times per second, that means you have about 16 milliseconds to do all your work per frame. What happens if your update logic takes 20 milliseconds? What would the user see?

Think about it...

You have a cube rotating with 'ref.current.rotation.y += 0.01' in useFrame. On a 60Hz monitor it completes one full rotation in ~10.5 seconds. How long does the same rotation take on a 144Hz monitor?

Hint: Think about how many times useFrame fires per second on each monitor...

Try These Challenges

Put what you learned into practice. Try each challenge in the demo above using the Leva controls, then check the solution.

Try This!

Beginner

Set speed to 5 — which cube goes crazy?

Try This!

Intermediate

Try Math.sin(clock.elapsedTime) for oscillation

Try This!

Beginner

Use delta * 0.1 for slow-motion

These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.

1
Creating objects inside the animation loop
Memory usage grows every frame, eventually crashing the tab
Don't do this
SpinningBox.tsxTSX
useFrame(() => {
  // WRONG: new Vector3 every frame = memory leak!
  const dir = new Vector3(1, 0, 0);
  ref.current.position.add(
    dir.multiplyScalar(0.01)
  );
});
Every 'new' keyword inside useFrame allocates memory 60 times per second. Over time this causes garbage collection pauses that appear as stuttering. Pre-allocate reusable objects outside the loop and mutate them with .set() or .copy().
2
Using fixed values instead of delta time
Animation runs at different speeds on different monitors
Don't do this
SpinningBox.tsxTSX
useFrame(() => {
  // WRONG: on a 144Hz monitor, this runs
  // 2.4x faster than on a 60Hz monitor!
  ref.current.rotation.y += 0.01;
});
useFrame fires once per display refresh. A 60Hz monitor calls it 60 times/sec, a 144Hz monitor calls it 144 times/sec. If you add a fixed value, the animation is 2.4x faster on the faster monitor. Multiply by delta to make speed consistent.
3
Heavy computations blocking the frame
Scene drops to 10fps because the loop does too much work
Don't do this
Particles.tsxTSX
useFrame(() => {
  // WRONG: sorting 10,000 items every frame
  const sorted = particles.sort((a, b) =>
    a.distanceTo(camera) - b.distanceTo(camera)
  );
  updatePositions(sorted);
});
You have about 16 milliseconds per frame at 60fps. Expensive operations like sorting large arrays, raycasting thousands of objects, or heavy math will blow past that budget and cause visible stuttering. Spread heavy work across frames or use web workers.

Best Practices

Always Use Delta Time

Multiply all movement and rotation speeds by delta. This ensures your animation runs at the same real-world speed on 60Hz, 144Hz, and variable-refresh displays.

Pre-Allocate Reusable Objects

Create Vector3, Color, and other objects outside useFrame using useRef or useMemo. Reuse them with .set() and .copy() to avoid garbage collection pauses.

Keep useFrame Lightweight

You have about 16ms per frame at 60fps. Heavy computations like sorting large arrays or complex raycasting should be spread across frames or offloaded to workers.

Render On Demand When Possible

For static scenes that only change on user interaction (like model viewers), use R3F's frameloop="demand" prop. This can reduce GPU usage from 100% to nearly 0% when idle, saving battery life.