🏁 Animating grid items
Hope you had fun with GSAP! You really should dig more with this lib if you don't know it well: it's trully amazing! Let's take a look at the animation we had in mind and how to replicate it.
Move grid items to the center of the screen
First task at hand is to move all the grid items to the center of the screen, which in our case is also the center of the parent container this.$grid. For that we need to compute the target coordinates, the center of the grid in our case. We'll use getBoundingClientRect() to do so.
// Define settings
const gridBCR = this.$grid.getBoundingClientRect();
const targetCoords = { // Compute the center of the grid
left: gridBCR.width/2,
top: gridBCR.height/2
};
Then we'll loop over each grid item to compute the translation needed to reach our target!
// Loop other gridItems to animate each individually
for(let i = 0; i < this.$gridItems.length; i++) {
const item = this.$gridItems[i]; // Store in a const for convenience
// Compute offsetToTarget
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = item; // Extract properties for better readability
const offsetToTarget = {
x: targetCoords.left - offsetLeft - offsetWidth/2,
y: targetCoords.top - offsetTop - offsetHeight/2
};
// Move the item to the target
this.tl.to(item, {
...offsetToTarget, // Extract `x` & `y` values from `offsetToTarget`
ease: 'power2.inOut' // Add a nice easing
}, 0); // 3rd param at 0 to make sure every item starts moving at timeline init
}
It's working! But it feels a bit weird, it lacks character, maybe we can add some staggering and randomness to it all.
Adding character
Delays, kinda
We can't really use the stagger property from gsap here since we need to handle each item's translation individually. We could increase the 3rd .to() parameter to delay each item but we tried and it feels too robotic...
Instead, we went for an increasing duration: all the items will start moving at the same time but for gradually longer. Resulting in a much more pleasant delay feeling!
computeTl() {
// Create the timeline
this.tl = gsap.timeline({});
// Define settings
const singleDuration = 1; // Our base timing unit
const gridBCR = this.$grid.getBoundingClientRect();
const targetCoords = { // Center of the grid
left: gridBCR.width/2,
top: gridBCR.height/2
};
// Loop other gridItems to animate each individually
for(let i = 0; i < this.$gridItems.length; i++) {
// The greater the index, the greater the duration (almost like a stagger but everyone starts at the same time)
let duration = singleDuration + .3 * i;
const item = this.$gridItems[i]; // Convenience
// Compute offsetToTarget
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = item; // Extract properties for better readability
const offsetToTarget = {
x: targetCoords.left - offsetLeft - offsetWidth/2,
y: targetCoords.top - offsetTop - offsetHeight/2
};
// Move the item to the target
this.tl.to(item, {
...offsetToTarget,
duration,
ease: 'power2.inOut'
}, 0);
}
this.tl.progress(0); // Set progress to 0
this.tl.pause(); // Very important to pause the timeline, we don't want it to play on its own: it's meant to be controlled by `onScrollProgress` only
}
Randomness
Already much better! We can also add some rotations & scale things randomly a bit for an even more organic feeling. We'll make an exception for the first grid item though, we want that one to reach the center of the screen quicker & without rotation/scale shenanigans!
computeTl() {
// Create the timeline
this.tl = gsap.timeline({});
// Define settings
const singleDuration = 1; // Our base timing unit
const gridBCR = this.$grid.getBoundingClientRect();
const targetCoords = { // Center of the grid
left: gridBCR.width/2,
top: gridBCR.height/2
};
// Loop other gridItems to animate each individually
for(let i = 0; i < this.$gridItems.length; i++) {
// The greater the index, the greater the duration (almost like a stagger but everyone starts at the same time)
let duration = singleDuration + .3 * i;
const item = this.$gridItems[i]; // Convenience
// Compute offsetToTarget
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = item; // Extract properties for better readability
const offsetToTarget = {
x: targetCoords.left - offsetLeft - offsetWidth/2,
y: targetCoords.top - offsetTop - offsetHeight/2
};
// Properties targets
let rotateZ = Math.random() * 30 - 15 // Randomize target rotation for a more organic look (between -15 and 15)
let scale = Math.random() * 0.3 + 0.3 // Randomize target scale for a more organic look (between .3 & .6)
// Specific settings for first item only
if(i == 0) {
rotateZ = 0;
scale = 1;
duration = singleDuration + .3 * (this.$gridItems.length/2); // Shorter duration
}
// Move the item to the target
this.tl.to(item, {
...offsetToTarget,
rotateZ,
scale,
duration,
ease: 'power2.inOut'
}, 0);
}
this.tl.progress(0); // Set progress to 0
this.tl.pause(); // Very important to pause the timeline, we don't want it to play on its own: it's meant to be controlled by `onScrollProgress` only
}
Adding a sense of depth
We're already pretty close to our end result regarding our grid items animation. But the devil is in the details as they say so let's focus on that! We'd like our first grid item to scale up for the whole animation to highlight it even more and increase the scrolling's visual feedback to the user.
The thing is, we're a bit stuck here because we're already animating the item element for a shorter duration, and the existing animation is already affecting the transform CSS property (not only for scaling, but also for x and y translates)... So we can't use the same .to() method, and we can't add another .to() because it'll enter in conflict with the previous one.
Our solution in that case is to target a child element: now you might get why we added data-fancy-gallery="image" to our image snippet! Using that other element resolves both issues described above! So let's go ahead with it!
computeTl() {
// Create the timeline
this.tl = gsap.timeline({});
// Define settings
const singleDuration = 1; // Our base timing unit
const gridBCR = this.$grid.getBoundingClientRect();
const targetCoords = { // Center of the grid
left: gridBCR.width/2,
top: gridBCR.height/2
};
// Loop other gridItems to animate each individually
for(let i = 0; i < this.$gridItems.length; i++) {
// The greater the index, the greater the duration (almost like a stagger but everyone starts at the same time)
let duration = singleDuration + .3 * i;
const item = this.$gridItems[i]; // Convenience
const image = this.$('image', item)[0]; // Select the child image
// Compute offsetToTarget
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = item; // Extract properties for better readability
const offsetToTarget = {
x: targetCoords.left - offsetLeft - offsetWidth/2,
y: targetCoords.top - offsetTop - offsetHeight/2
};
// Properties targets
let rotateZ = Math.random() * 30 - 15 // Randomize target rotation for a more organic look (between -15 and 15)
let scale = Math.random() * 0.3 + 0.3 // Randomize target scale for a more organic look (between .3 & .6)
// Specific settings for first item only
if(i == 0) {
rotateZ = 0;
scale = 1;
duration = singleDuration + .3 * (this.$gridItems.length/2);
// Scaling is handled on image instead of item to avoid transform conflicts & to allow different duration or easing settings
this.tl.to(image, {
scale: 1.2,
ease: 'power2.inOut',
duration: singleDuration + .3 * this.$gridItems.length // Make it last for the whole timeline!
}, 0);
}
// Move the item to the target
this.tl.to(item, {
...offsetToTarget,
rotateZ,
scale,
duration,
ease: 'power2.inOut'
}, 0);
}
this.tl.progress(0); // Set progress to 0
this.tl.pause(); // Very important to pause the timeline, we don't want it to play on its own: it's meant to be controlled by `onScrollProgress` only
}
When your animation gets complex and you need multiple things happening at once on the same element, keep in mind you can nest things a bit to get unstuck! But be wary not to overload the DOM size.
Let's use the same child image trick to add an opacity animation to grid items (except the first one)!
computeTl() {
// Create the timeline
this.tl = gsap.timeline({});
// Define settings
const singleDuration = 1; // Our base timing unit
const gridBCR = this.$grid.getBoundingClientRect();
const targetCoords = { // Center of the grid
left: gridBCR.width/2,
top: gridBCR.height/2
};
// Loop other gridItems to animate each individually
for(let i = 0; i < this.$gridItems.length; i++) {
// The greater the index, the greater the duration (almost like a stagger but everyone starts at the same time)
let duration = singleDuration + .3 * i;
const item = this.$gridItems[i]; // Convenience
const image = this.$('image', item)[0]; // Select the child image
// Compute offsetToTarget
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = item; // Extract properties for better readability
const offsetToTarget = {
x: targetCoords.left - offsetLeft - offsetWidth/2,
y: targetCoords.top - offsetTop - offsetHeight/2
};
// Properties targets
let rotateZ = Math.random() * 30 - 15 // Randomize target rotation for a more organic look (between -15 and 15)
let scale = Math.random() * 0.3 + 0.3 // Randomize target scale for a more organic look (between .3 & .6)
let opacity = 0.2
// Specific settings for first item only
if(i == 0) {
rotateZ = 0;
scale = 1;
opacity = 1;
duration = singleDuration + .3 * (this.$gridItems.length/2);
// Scaling is handled on image instead of item to avoid transform conflicts & to allow different duration or easing settings
this.tl.to(image, {
scale: 1.2,
ease: 'power2.inOut',
duration: singleDuration + .3 * this.$gridItems.length // Make it last for the whole timeline!
}, 0);
}
// Move the item to the target
this.tl.to(item, {
...offsetToTarget,
rotateZ,
scale,
duration,
ease: 'power2.inOut'
}, 0);
// Fade image out as it moves to emulate depth / distance fog
this.tl.to(image, {
opacity,
duration: duration * 0.8, // A bit shorter than the actual animation looks better here
ease: 'power1.in'
}, 0);
}
this.tl.progress(0); // Set progress to 0
this.tl.pause(); // Very important to pause the timeline, we don't want it to play on its own: it's meant to be controlled by `onScrollProgress` only
}
Handling resize events
The following code is required and in a real-life production you'd have to go through it. For the purpose of this live-coding workshop though, we're going to skip it as it's not fun to go through and it'll be included in the next snapshot.
We encourage you to come back here after the workshop to go through this.
- The idea is to kill the timeline and replace it by a freshly computed one
- We're not having any grid animation on devices under 700px wide
Our animation is sick! But some of you may have noticed it completely breaks under resizing 😅
That's the less fun part we have to take care of because we built our animation with computed transforms in px...

Don't flip your table yet, it's not that bad! The idea here is to re-compute the whole timeline when a resize happens. With that happening, we need to make sure the old timeline is properly discarded & that all affected elements properties are reseted and ready to be animated again.
import gsap from 'gsap'
import { module } from 'modujs'
import { CUSTOM_EVENT } from '../config';
export default class extends module {
constructor(m) {
super(m);
// Element Selectors
this.$sticky = this.$('sticky')[0];
this.$grid = this.$('grid')[0];
this.$gridItems = Array.from(this.$('grid-item'));
this.$images = Array.from(this.$('image'));
}
init() {
// We need to handle resize because we're computing sizings/spacings in raw pixels
this.computeTl = this.computeTl.bind(this);
window.addEventListener(CUSTOM_EVENT.RESIZE_END, this.computeTl);
// Start timeline stuff
this.computeTl();
}
computeTl() {
// Get rid of old timeline & reset everything
const progress = this.tl?.progress?.() ?? 0;
this.tl?.kill?.();
this.tl = null; // Safety
gsap.set([...this.$gridItems,this.$images], { clearProps: 'all' });
// If we're on mobile-ish, we want the simplified layout w/ out any grid animation, so don't go further
if(window.innerWidth < 700) return;
// RAF to make sure clearProps is effective before new computing
requestAnimationFrame(() => {
// Create the timeline
this.tl = gsap.timeline({});
// Define settings
const singleDuration = 1; // Our base timing unit
const gridBCR = this.$grid.getBoundingClientRect();
const targetCoords = { // Center of the grid
left: gridBCR.width/2,
top: gridBCR.height/2
};
// Loop other gridItems to animate each individually
for(let i = 0; i < this.$gridItems.length; i++) {
// The greater the index, the greater the duration (almost like a stagger but everyone starts at the same time)
// let duration = singleDuration + .3 * i;
const item = this.$gridItems[i]; // Convenience
const image = this.$('image', item)[0]; // Select the child image
// Compute offsetToTarget
const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = item; // Extract properties for better readability
const offsetToTarget = {
x: targetCoords.left - offsetLeft - offsetWidth/2,
y: targetCoords.top - offsetTop - offsetHeight/2
};
// Properties targets
let rotateZ = Math.random() * 30 - 15, // Randomize target rotation for a more organic look (between -15 and 15)
scale = Math.random() * 0.3 + 0.3, // Randomize target scale for a more organic look (between .3 & .6)
opacity = 0.2;
// Specific settings for first item only
if(i == 0) {
rotateZ = 0;
scale = 1;
opacity = 1;
duration = singleDuration + .3 * (this.$gridItems.length/2);
// Scaling is handled on image instead of item to avoid transform conflicts & to allow different duration or easing settings
this.tl.to(image, {
scale: 1.2,
ease: 'power2.inOut',
duration: singleDuration + .3 * this.$gridItems.length
}, 0);
}
// Move the item to the target
this.tl.to(item, {
...offsetToTarget,
rotateZ,
scale,
duration,
ease: 'power2.inOut'
}, 0);
// Fade image out as it moves to emulate depth / distance fog
this.tl.to(image, {
opacity,
duration: duration * 0.8,
ease: 'power1.in'
}, 0);
}
this.tl.progress(progress); // Set progress to 0 or old progress value (if we're in a resize event)
this.tl.pause(); // Very important to pause the timeline, we don't want it to play on its own: it's meant to be controlled by `onScrollProgress` only
});
}
// Allows to progress through our timeline relatively to the data-scroll progress given
onScrollProgress(value) {
this.tl?.progress?.(value);
}
destroy() {
super.destroy();
window.removeEventListener(CUSTOM_EVENT.RESIZE_END, this.computeTl);
this.tl?.kill?.();
}
}
We also chose not to animate the grid items on mobile-ish devices, hence the return on devices slimmer than 700px wide. The design only shows 3 items and handling another mobile specific timeline for mobile would be too tedious for this workshop and frankly, for our minds as well 😆
But wait, there's more!
