Animating Camera Transitions in Three.js Using GSAP

Published on 10 May, 2025 | ~10 min read | Demo

Animation is key to crafting immersive experiences. And we're not just talking about moving objects, meshes, or colors—it's all about animating the most essential part of the scene: the camera.

That being said, in this article, we’ll look at how to create smooth camera transitions using GSAP, the popular JavaScript animation library.

Camera Transitions Without a Library

You might not be convinced why we need a library like GSAP to animate the camera—so let’s try doing it without any third-party libraries first.

First things first, you'll need a Three.js project up and running. If you don’t have one yet, feel free to use my boilerplate to get started.

Next, add a cube or any object to make it easier to see how the camera moves.

import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Sets the color of the background.
renderer.setClearColor(0xfefefe);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

camera.position.set(0, 2, 5);
camera.lookAt(0, 0, 0);

// Creates a 12 by 12 grid helper.
const gridHelper = new THREE.GridHelper(20, 20);
scene.add(gridHelper);

const ambientLight = new THREE.AmbientLight(0x87ceeb, 0.3);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
scene.add(directionalLight);
directionalLight.position.set(10, 20, 1);

const boxGeo = new THREE.BoxGeometry();
const boxMat = new THREE.MeshPhongMaterial({ color: 0x00ff00 });
const boxMesh = new THREE.Mesh(boxGeo, boxMat);
boxMesh.position.y = 0.5;
scene.add(boxMesh);

function animate() {
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

window.addEventListener('resize', function () {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

Let’s say we want to move the camera backward.

What comes to mind—and maybe it's the same for you—is to just change the position using the set() method or by tweaking each property manually. So let’s try that.

window.addEventListener('click', function () {
  camera.position.z = 14;
});

Animated Demo.

As you can see, the camera moves to the desired position, but it happens instantly. That’s because the z value is changed directly from its initial to final position, with no progression over time.

That said, to change the camera position over time, we’ll create two variables.

The first will store the values for the camera’s z coordinate, and its value will depend on time.

The second variable will hold the final position of the camera. Once the z value reaches that, it will stop increasing.

let z;
const zFinal = 14;

window.addEventListener('click', function () {
  z = camera.position.z;
});

Next, we’ll jump into the animate() function and increase the z value by 0.1 each frame. We’ll also set the stop condition.

function animate() {
  z += 0.1;
  if (z < zFinal) camera.position.z = z;
  
  renderer.render(scene, camera);
}

Animated Demo.

Now, even though this method works, I really don’t recommend using it. You’ll quickly end up juggling a bunch of variables and messy code—and on top of that, it’s not optimized at all.

Furthermore, imagine if we wanted to add camera movement on the y and z axes too—we’d need four more variables and even more lines of code with extra checks in the animate() function.

On top of that, you won’t be able to add easing functions to the animation without writing more code, which adds even more complexity to your codebase.

Creating Camera Transitions With GSAP

First and foremost, we need to install GSAP by running the following command in the terminal: npm install gsap.

Next, we need to import it into our project.

import gsap from 'gsap';

Now we can recreate the same animation as before using just a few simple and concise lines of code.

// let z;
// const zFinal = 14;

window.addEventListener('click', function () {
  // z = camera.position.z;

  gsap.to(camera.position, {
    z: 14,
    duration: 1.5,
  });
});

function animate() {
  // z += 0.1;
  // if (z < zFinal) camera.position.z = z;

  renderer.render(scene, camera);
}

Animated Demo.

We can also use the onUpdate property to adjust the camera’s direction as its position changes.

window.addEventListener('click', function () {

  gsap.to(camera.position, {
    z: 14,
    duration: 1.5,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  });
  
});

This keeps the camera pointed toward the origin of the scene.

Animated Demo.

We can call the to() method multiple times to run two animations at the same time.

window.addEventListener('click', function () {
  gsap.to(camera.position, {
    z: 14,
    duration: 1.5,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  });

  gsap.to(camera.position, {
    y: 10,
    duration: 1.5,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  });
  
});

Animated Demo.

We can also use a timeline when we need to create more complex animations.

We could, for example, set up an animation that goes through three phases.

  1. Pull the camera back.
  2. Move the camera upward.
  3. Apply movement on all three axes.
const tl = gsap.timeline();

window.addEventListener('click', function () {
  tl.to(camera.position, {
    z: 14,
    duration: 1.5,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  })

    .to(camera.position, {
      y: 10,
      duration: 1.5,
      onUpdate: function () {
        camera.lookAt(0, 0, 0);
      },
    })

    .to(camera.position, {
      x: 10,
      y: 5,
      z: 3,
      duration: 1.5,
      onUpdate: function () {
        camera.lookAt(0, 0, 0);
      },
    });
});

Animated Demo.

Let's Recreate the Demo

I won’t go into how I cloned and animated the models, since the main focus of this article is camera animations.

That being said, here’s the starting code for the demo. As for the model, you’ll find the download link in the credits section below.

import * as THREE from 'three';
import { GLTFLoader, SkeletonUtils } from 'three/examples/jsm/Addons.js';
import gsap from 'gsap';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Sets the color of the background.
renderer.setClearColor(0xfefefe);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

// Camera positioning.
camera.position.set(10, 0, 20);

const ambientLight = new THREE.AmbientLight(0x87ceeb, 0.3);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
scene.add(directionalLight);
directionalLight.position.set(10, 20, 1);

const loader = new GLTFLoader();

loader.load('./unreal_engine_4_sky.glb', function (glb) {
  const model = glb.scene;
  model.scale.set(2, 2, 2);
  scene.add(model);
});

let mixer, mixer2, mixer3;

loader.load('./phoenix_bird.glb', function (glb) {
  const model = glb.scene;
  model.scale.set(0.01, 0.01, 0.01);

  const model2 = SkeletonUtils.clone(model);
  const model3 = SkeletonUtils.clone(model);

  model2.position.set(7, -4, 6);
  model3.position.set(-7, 4, -2);

  scene.add(model);
  scene.add(model2);
  scene.add(model3);

  mixer = new THREE.AnimationMixer(model);
  mixer2 = new THREE.AnimationMixer(model2);
  mixer3 = new THREE.AnimationMixer(model3);

  const clips = glb.animations;
  const clip = THREE.AnimationClip.findByName(clips, 'Take 001');

  const action = mixer.clipAction(clip);
  const action2 = mixer2.clipAction(clip);
  const action3 = mixer3.clipAction(clip);

  action.play();
  action.timeScale = 0.5;

  action2.play();
  action2.startAt(0.2);
  action2.timeScale = 0.5;

  action3.play();
  action3.startAt(0.35);
  action3.timeScale = 0.5;

  window.addEventListener('mousedown', animateCamera);
});

function animateCamera() {
  // The camera animations logic goes here
}

const clock = new THREE.Clock();

function animate() {
  const delta = clock.getDelta();
  if (mixer && mixer2 && mixer3) {
    mixer.update(delta);
    mixer2.update(delta);
    mixer3.update(delta);
  }
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

window.addEventListener('resize', function () {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

First, we need to set up the following variables.

const tl = gsap.timeline({
  defaults: {
    duration: 8,
    ease: 'none',
  },
});

let animationIsFinished = false;

function animateCamera() {
  // The camera animations logic goes here
}

Now we’ll start with the first part of the animation—a camera move to the left, without updating its direction.

function animateCamera() {
  if (!animationIsFinished) {
    // Once the animation starts, it’s considered finished
    // so clicking again won’t trigger it a second time.
    animationIsFinished = true;

    tl.to(camera.position, {
      x: 0,
      duration,
      ease,
    });
  }
}

In the second part, we’ll move the camera along the x, y, and z axes and update its direction this time.

tl.to(camera.position, {
  x: 0,
  duration,
  ease,
})
.to(
  camera.position,
  {
    x: -30,
    y: 30,
    z: 20,
    duration,
    ease,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  }
);

Next, we’ll add another sequence, and we’ll increase the delay to 14 seconds.

This way, the first 2 seconds of this sequence will overlap with the last 2 seconds of the previous one, and the remaining 6 seconds will play afterward.

tl.to(camera.position, {
  x: 0,
  duration,
  ease,
})
.to(
  camera.position,
  {
    x: -30,
    y: 30,
    z: 20,
    duration,
    ease,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  }
)
.to(
  camera.position,
  {
    x: -40,
    z: -20,
    duration,
    ease,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  },
  14
);

Now, we’ll copy the previous block and just change the x, y, and z values.

tl.to(camera.position, {
  x: 0,
  duration,
  ease,
})
.to(
  camera.position,
  {
    x: -30,
    y: 30,
    z: 20,
    duration,
    ease,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  }
)
.to(
  camera.position,
  {
    x: -40,
    z: -20,
    duration,
    ease,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  },
  14
)
.to(camera.position, {
  x: 5,
  y: 5,
  z: -10,
  duration,
  ease,
  onUpdate: function () {
    camera.lookAt(0, 0, 0);
  },
});

As you can see, I removed the delay, so this sequence will run once the previous one is finished.

We’ll do the same thing in the next sequence, but this time we’ll use the string '>-0.2' for the delay. It basically means 'start this sequence 0.2 seconds before the previous one ends'.

We’re essentially adding a delay just like in the previous sequences, but using a relative value instead. So feel free to use whichever method makes more sense to you.

tl.to(camera.position, {
  x: 0,
  duration,
  ease,
})
.to(camera.position, {
  x: -30,
  y: 30,
  z: 20,
  duration,
  ease,
  onUpdate: function () {
    camera.lookAt(0, 0, 0);
  },
})
.to(
  camera.position,
  {
    x: -40,
    z: -20,
    duration,
    ease,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  },
  14
)
.to(camera.position, {
  x: 5,
  y: 5,
  z: -10,
  duration,
  ease,
  onUpdate: function () {
    camera.lookAt(0, 0, 0);
  },
})
.to(
  camera.position,
  {
    y: 20,
    z: 30,
    duration,
    ease,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  },
  '>-0.2'
);

Finally, we’ll add one more horizontal movement to wrap up the last phase of our animation.

tl.to(camera.position, {
  x: 0,
  duration,
  ease,
})
.to(camera.position, {
  x: -30,
  y: 30,
  z: 20,
  duration,
  ease,
  onUpdate: function () {
    camera.lookAt(0, 0, 0);
  },
})
.to(
  camera.position,
  {
    x: -40,
    z: -20,
    duration,
    ease,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  },
  14
)
.to(camera.position, {
  x: 5,
  y: 5,
  z: -10,
  duration,
  ease,
  onUpdate: function () {
    camera.lookAt(0, 0, 0);
  },
})
.to(
  camera.position,
  {
    y: 20,
    z: 30,
    duration,
    ease,
    onUpdate: function () {
      camera.lookAt(0, 0, 0);
    },
  },
  '>-0.2'
)
.to(camera.position, {
  x: -30,
  duration: 12,
  delay: 0.1,
  ease,
});

Wrap Up

I hope you found this read insightful.

There’s more coming on this topic, so stay tuned!

Buy me a coffee

Credits and Resources

Related Content