From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
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.
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:
- Map pixel units to camera’s view units
- Make the units relative to the center instead of the top left
- Make the position’s origin start on the plane’s center, not on the top left
- 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 animationuMeshScale
– Scale of the meshuMeshPosition
– Mesh’s position from the centeruViewSize
– 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:
- Calculate the scale needed to match the screen size using our mesh’s scale
- Move the vertices by their negative position so they move to the center
- 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:
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.
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:
Activation based on mouse position, and deformation with noise:
Distance deformation and mouse position activation:
We hope you enjoyed this tutorial and find it helpful!
Amazingly well explained, tank you very much !
Very informative. Thank you!
nice tutorial but on my mac book air the effect is extremely slow and the texture pixelated
as cool as the mac dock animation!
This is gold, thank you so much for this 🙂
Nice tutorial thank you will implement it in the upcoming project.
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!
nice nice