Creating Grid-to-Fullscreen Animations with Three.js

Learn how to create thumbnail to fullscreen animations for image grids using Three.js.

Animations play a big role in how users feels about your website. They convey a lot of the personality and feel of your site. They also help the user navigate new and already known screens with more ease.

In this tutorial we want to look at how to create some interesting grid-to-fullscreen animations on images. The idea is to have a grid of smaller images and when clicking on one, the image enlarges with a special animation to cover the whole screen. We’ll aim for making them accessible, unique and visually appealing. Additionally, we want to show you the steps for making your own.

The building blocks

Before we can start doing all sorts of crazy animations, timing calculations and reality deformation we need to get the basic setup of the effect ready:

  • Initialize Three.js and the plane we’ll use
  • Position and scale the plane so it is similar to the item’s image whenever the user clicks an item
  • Animate the plane so it covers the complete screen

For the sake of not going too crazy with all the effects we can make, we’ll focus on making a flip effect like the one in our first demo.

GridFullscreen_demo1

Initialization

To begin, lets make a basic Three.js setup and add a single 1×1 plane which we’ll re-use for the animation of every grid item. Since only one animation can happen at the time. We can have better performance by only using one plane for all animations.

This simple change is going to allow us to have any number of HTML items without affecting the performance of the animation.

As a side note, in our approach we decided to only use Three.js for the time of the animation. This means all the items are good old HTML.

This allows our code to have a natural fallback for browsers that don’t have WebGL support. And it also makes our effect more accessible.

class GridToFullscreenEffect {
	...
	init(){
		... 
		const segments = 128;
		var geometry = new THREE.PlaneBufferGeometry(1, 1, segments, segments);
		// We'll be using the shader material later on ;)
		var material = new THREE.ShaderMaterial({
		  side: THREE.DoubleSide
		});
		this.mesh = new THREE.Mesh(geometry, material);
		this.scene.add(this.mesh);
	}
}

Note: We are skipping over the Three.js initialization since it’s pretty basic.

Setting the the plane geometry’s size to be 1×1 simplifies things a little bit. It removes a some of the math involved with calculating the correct scale. Since 1 scaled by any number is always going to return that same number.

Positioning and resizing

Now, we’ll resize and position the plane to match the item’s image. To do this, we’ll need to get the item’s getBoundingClientRect. Then we need to transform its values from pixels to the camera’s view units. After, we need to transform them from relative to the top left, to relative from the center. Summarized:

  1. Map pixel units to camera’s view units
  2. Make the units relative to the center instead of the top left
  3. Make the position’s origin start on the plane’s center, not on the top left
  4. Scale and position the mesh using these new values
class GridToFullscreenEffect {
...
 onGridImageClick(ev,itemIndex){
	// getBoundingClientRect gives pixel units relative to the top left of the pge
	 const rect = ev.target.getBoundingClientRect();
	const viewSize = this.getViewSize();
	
	// 1. Transform pixel units to camera's view units
	const widthViewUnit = (rect.width * viewSize.width) / window.innerWidth;
	const heightViewUnit = (rect.height * viewSize.height) / window.innerHeight;
	let xViewUnit =
	  (rect.left * viewSize.width) / window.innerWidth;
	let yViewUnit =
	  (rect.top * viewSize.height) / window.innerHeight;
	
	// 2. Make units relative to center instead of the top left.
	xViewUnit = xViewUnit - viewSize.width / 2;
	yViewUnit = yViewUnit - viewSize.height / 2;
   

	// 3. Make the origin of the plane's position to be the center instead of top Left.
	let x = xViewUnit + widthViewUnit / 2;
	let y = -yViewUnit - heightViewUnit / 2;

	// 4. Scale and position mesh
	const mesh = this.mesh;
	// Since the geometry's size is 1. The scale is equivalent to the size.
	mesh.scale.x = widthViewUnit;
	mesh.scale.y = heightViewUnit;
	mesh.position.x = x;
	mesh.position.y = y;

	}
 }

As a side note, scaling the mesh instead of scaling the geometry is more performant. Scaling the geometry actually changes its internal data which is slow and expensive, while scaling the mesh happens at rendering. This decision will come into play later on, so keep it in mind.

Now, bind this function to each item’s onclick event. Then our plane resizes to match the item’s image.

It’s a very simple concept, yet quite performant in the long run. Now that our plane is ready to go when clicked, lets make it cover the screen.

Basic animation

First, lets initialize the few uniforms:

  • uProgress – Progress of the animation
  • uMeshScale – Scale of the mesh
  • uMeshPosition – Mesh’s position from the center
  • uViewSize – Size of the camera’s view

We’ll also create the base for our shaders.

class GridToFullscreenEffect {
	constructor(container, items){
		this.uniforms = {
		  uProgress: new THREE.Uniform(0),
		  uMeshScale: new THREE.Uniform(new THREE.Vector2(1, 1)),
		  uMeshPosition: new THREE.Uniform(new THREE.Vector2(0, 0)),
		  uViewSize: new THREE.Uniform(new THREE.Vector2(1, 1)),
		}
	}
	init(){
		... 
		const viewSize = this.getViewSize();
		this.uniforms.uViewSize.x = viewSize.width;
		this.uniforms.uViewSize.y = viewSize.height;
		var material = new THREE.ShaderMaterial({
			uniform: this.uniforms,
			vertexShader: vertexShader,
			fragmentShader: fragmentShader,
			side: THREE.DoubleSide
		});
		
		...
	}
	...
}
const vertexShader = `
	uniform float uProgress;
	uniform vec2 uMeshScale;
	uniform vec2 uMeshPosition;
	uniform vec2 uViewSize;

	void main(){
		vec3 pos = position.xyz;
		 gl_Position = projectionMatrix * modelViewMatrix * vec4(pos,1.);
	}
`;
const fragmentShader = `
	void main(){
		 gl_FragColor = vec4(vec3(0.2),1.);
	}
`;

We need to update uMeshScale and uMeshPositon uniforms whenever we click an item.

class GridToFullscreenEffect {
	...
	onGridImageClick(ev,itemIndex){
		...
		// Divide by scale because on the fragment shader we need values before the scale 
		this.uniforms.uMeshPosition.value.x = x / widthViewUnit;
		this.uniforms.uMeshPosition.value.y = y / heightViewUnit;

		this.uniforms.uMeshScale.value.x = widthViewUnit;
		this.uniforms.uMeshScale.value.y = heightViewUnit;
	}
}

Since we scaled the mesh and not the geometry, on the vertex shader our vertices still represent a 1×1 square in the center of the scene. But it ends up rendered in another position and with a different size because of the mesh. As a consequence of this optimization, we need to use “down-scaled” values in the vertex shaders. With that out of the way, lets make the effect happen in our vertex Shader:

  1. Calculate the scale needed to match the screen size using our mesh’s scale
  2. Move the vertices by their negative position so they move to the center
  3. Multiply those values by the progress of the effect
...
const vertexShader = `
	uniform float uProgress;
	uniform vec2 uPlaneSize;
	uniform vec2 uPlanePosition;
	uniform vec2 uViewSize;

	void main(){
		vec3 pos = position.xyz;
		
		// Scale to page view size/page size
		vec2 scaleToViewSize = uViewSize / uPlaneSize - 1.;
		vec2 scale = vec2(
		  1. + scaleToViewSize * uProgress
		);
		pos.xy *= scale;
		
		// Move towards center 
		pos.y += -uPlanePosition.y * uProgress;
		pos.x += -uPlanePosition.x * uProgress;
		
		
		 gl_Position = projectionMatrix * modelViewMatrix * vec4(pos,1.);
	}
`;

Now, when we click an item. We are going to:

  • set our canvas container on top of the items
  • make the HTML item invisible
  • tween uProgress between 0 and 1
class GridToFullscreenEffect {
	...
	constructor(container,items){
		...
		this.itemIndex = -1;
		this.animating = false;
		this.state = "grid";
	}
	toGrid(){
		if (this.state === 'grid' || this.isAnimating) return;
		this.animating = true;
		this.tween = TweenLite.to(
		  this.uniforms.uProgress,1.,
		  {
			value: 0,
			onUpdate: this.render.bind(this),
			onComplete: () => {
			  this.isAnimating = false;
			  this.state = "grid";
			this.container.style.zIndex = "0";
			}
		  }
		);
	}
	toFullscreen(){
	if (this.state === 'fullscreen' || this.isAnimating) return;
		this.animating = true;
		this.container.style.zIndex = "2";
		this.tween = TweenLite.to(
		  this.uniforms.uProgress,1.,
		  {
			value: 1,
			onUpdate: this.render.bind(this),
			onComplete: () => {
			  this.isAnimating = false;
			  this.state = "fullscreen";
			}
		  }
		);
	}

	onGridImageClick(ev,itemIndex){
		...
		this.itemIndex = itemIndex;
		this.toFullscreen();
	}
}

We start the tween whenever we click an item. And there you go, our plane goes back and forth no matter which item we choose.

Pretty good, but not too impressive yet.

Now that we have the basic building blocks done, we can start making the cool stuff. For starters, lets go ahead and add timing.

Activation and timing

Scaling the whole plane is a little bit boring. So, lets give it some more flavor by making it scale with different patterns: Top-to-bottom, left-to-right, topLeft-to-bottomRight.

Lets take a look at how those effects behave and figure out what we need to do:

Grid Effects

By observing the effects for a minute, we can notice that the effect is all about timing. Some parts of the plane start later than others.

What we are going to do is to create an “activation” of the effect. We’ll use that activation to determine which vertices are going to start later than others.

Effects with activations

And lets see how that looks like in code:

...
const vertexShader = `
	...
	void main(){
		vec3 pos = position.xyz;
		
		// Activation for left-to-right
		float activation = uv.x;
		
		float latestStart = 0.5;
		float startAt = activation * latestStart;
		float vertexProgress = smoothstep(startAt,1.,uProgress);
	   
		...
	}
`;

We’ll replace uProgress with vertexprogres for any calculations in the vertex shader.

...
const vertexShader = `
	...
	void main(){
		...
		float vertexProgress = smoothstep(startAt,1.,uProgress);
		
		vec2 scaleToViewSize = uViewSize / uMeshScale - 1.;
		vec2 scale = vec2(
		  1. + scaleToViewSize * vertexProgress
		);
		pos.xy *= scale;
		
		// Move towards center 
		pos.y += -uMeshPosition.y * vertexProgress;
		pos.x += -uMeshPosition.x * vertexProgress;
		...
	}
`;

With this little change, our animation is not much more interesting.

Note that the gradients on the demo are there for demonstration purposes. They have nothing to do with the effect itself.

The great thing about these “activation” and “timing” concepts is that they are interchangeable implementations. This allows us to create a ton of variations.

With the activation and timing in place, lets make it more interesting with transformations.

Transformations

If you haven’t noticed, we already know how to make a transformation. We successfully scaled and moved the plane forwards and backwards.

We interpolate or move from one state to another using vertexProgress. Just like we are doing in the scale and movement:

...
const vertexShader = `
	...
	void main(){
	...
		// Base state = 1.
		// Target state = uScaleToViewSize;
		// Interpolation value: vertexProgress
		scale = vec2(
		  1. + uScaleToViewSize * vertexProgress
		);

		// Base state = pos
		// Target state = -uPlaneCenter;
		// Interpolation value: vertexProgress
		pos.y += -uPlaneCenter.y * vertexProgress;
		pos.x += -uPlaneCenter.x * vertexProgress;
	...
	}
`

Lets apply this same idea to make a flip transformation:

  • Base state: the vertex’s current position
  • Target state: The vertex flipped position
  • Interpolate with: the vertex progress
...
const vertexShader = `
	...
	void main(){
		...
		float vertexProgress = smoothstep(startAt,1.,uProgress);
		// Base state: pos.x
		// Target state: flippedX
		// Interpolation with: vertexProgress 
		float flippedX = -pos.x;
		pos.x = mix(pos.x,flippedX, vertexProgress);
		// Put vertices that are closer to its target in front. 
		pos.z += vertexProgress;
		...
	}
`;

Note that, because this flip sometimes puts vertices on top of each other we need to bring some of them slightly to the front to make it look correctly.

Combining these flips with different activations, these are some of the variations we came up with:

If you pay close attention to the flip you’ll notice it also flips the color/image backwards. To fix this issue we have to flip the UVs along with the position.

And there we have it! We’ve not only created an interesting and exciting flip effect, but also made sure that using this structure we can discover all kinds of effects by changing one or more of the pieces.

In fact, we created the effects seen in our demos using the configurations as part of our creative process.

There is so much more to explore! And we would love to see what you can come up with.

Here are the most interesting variations we came up with:

Different timing creation:

GridFullscreen_demo2

Activation based on mouse position, and deformation with noise:

GridFullscreen_demo4

Distance deformation and mouse position activation:

GridFullscreen_demo5

We hope you enjoyed this tutorial and find it helpful!

Daniel Velasquez

Daniel is a freelance front-end developer with a passion for interactive experiences. He enjoys deconstructing interesting websites and sharing ideas. Owns a diary and is writing about you right now.

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!

Feedback 8

Comments are closed.
  1. nice tutorial but on my mac book air the effect is extremely slow and the texture pixelated

  2. Great tutorials. Easy to understand and love the way the activation works. As a user myself, I’d be impressed with a site which has a little bit of such creativity added to it. Great post Daniel. Way to go!