Free course recommendation: Master JavaScript animation with GSAP through 34 free video lessons, step-by-step projects, and hands-on demos. Enroll now →
You’ve probably seen this kind of scroll effect before, even if it doesn’t have a name yet. (Honestly, we need a dictionary for all these weird and wonderful web interactions. If you’ve got a talent for naming things…do it. Seriously. The internet is waiting.)
Imagine a grid of images. As you scroll, the columns don’t move uniformly but instead, the center columns react faster, while those on the edges trail behind slightly. It feels soft, elastic, and physical, almost like scrolling with weight, or elasticity.
You can see this amazing effect on sites like yzavoku.com (and I’m sure there’s a lot more!).
So what better excuse to use the now-free GSAP ScrollSmoother? We can recreate it easily, with great performance and full control. Let’s have a look!
What We’re Building
We’ll take CSS grid based layout and add some magic:
- Inertia-based scrolling using ScrollSmoother
- Per-column lag, calculated dynamically based on distance from the center
- A layout that adapts to column changes
HTML Structure
Let’s set up the markup with figures in a grid:
<div class="grid">
<figure class="grid__item">
<div class="grid__item-img" style="background-image: url(assets/1.webp)"></div>
<figcaption class="grid__item-caption">Zorith - L91</figcaption>
</figure>
<!-- Repeat for more items -->
</div>
Inside the grid, we have many .grid__item figures, each with a background image and a label. These will be dynamically grouped into columns by JavaScript, based on how many columns CSS defines.
CSS Grid Setup
.grid {
display: grid;
grid-template-columns: repeat(var(--column-count), minmax(var(--column-size), 1fr));
grid-column-gap: var(--c-gap);
grid-row-gap: var(--r-gap);
}
.grid__column {
display: flex;
flex-direction: column;
gap: var(--c-gap);
}
We define all the variables in our root.
In our JavaScript then, we’ll change the DOM structure by inserting .grid__column wrappers around groups of items, one per colum, so we can control their motion individually. Why are we doing this? It’s a bit lighter to move columns rather then each individual item.
JavaScript + GSAP ScrollSmoother
Let’s walk through the logic step-by-step.
1. Enable Smooth Scrolling and Lag Effects
gsap.registerPlugin(ScrollTrigger, ScrollSmoother);
const smoother = ScrollSmoother.create({
smooth: 1, // Inertia intensity
effects: true, // Enable per-element scroll lag
normalizeScroll: true, // Fixes mobile inconsistencies
});
This activates GSAP’s smooth scroll layer. The effects: true
flag lets us animate elements with lag, no scroll listeners needed.
2. Group Items Into Columns Based on CSS
const groupItemsByColumn = () => {
const gridStyles = window.getComputedStyle(grid);
const columnsRaw = gridStyles.getPropertyValue('grid-template-columns');
const numColumns = columnsRaw.split(' ').filter(Boolean).length;
const columns = Array.from({ length: numColumns }, () => []); // Initialize column arrays
// Distribute grid items into column buckets
grid.querySelectorAll('.grid__item').forEach((item, index) => {
columns[index % numColumns].push(item);
});
return { columns, numColumns };
};
This method groups your grid items into arrays, one for each visual column, using the actual number of columns calculated from the CSS.
3. Create Column Wrappers and Assign Lag
const buildGrid = (columns, numColumns) => {
const fragment = document.createDocumentFragment(); // Efficient DOM batch insertion
const mid = (numColumns - 1) / 2; // Center index (can be fractional)
const columnContainers = [];
// Loop over each column
columns.forEach((column, i) => {
const distance = Math.abs(i - mid); // Distance from center column
const lag = baseLag + distance * lagScale; // Lag based on distance from center
const columnContainer = document.createElement('div'); // New column wrapper
columnContainer.className = 'grid__column';
// Append items to column container
column.forEach((item) => columnContainer.appendChild(item));
fragment.appendChild(columnContainer); // Add to fragment
columnContainers.push({ element: columnContainer, lag }); // Save for lag effect setup
});
grid.appendChild(fragment); // Add all columns to DOM at once
return columnContainers;
};
The lag value increases the further a column is from the center, creating that elastic “catch up” feel during scroll.
4. Apply Lag Effects to Each Column
const applyLagEffects = (columnContainers) => {
columnContainers.forEach(({ element, lag }) => {
smoother.effects(element, { speed: 1, lag }); // Apply individual lag per column
});
};
ScrollSmoother handles all the heavy lifting, we just pass the desired lag.
5. Handle Layout on Resize
// Rebuild the layout only if the number of columns has changed on window resize
window.addEventListener('resize', () => {
const newColumnCount = getColumnCount();
if (newColumnCount !== currentColumnCount) {
init();
}
});
This ensures our layout stays correct across breakpoints and column count changes (handled via CSS).
And that’s it!
Extend This Further
Now, there’s lots of ways to build upon this and add more jazz!
For example, you could:
- add scroll-triggered opacity or scale animations
- use scroll velocity to control effects (see demo 2)
- adapt this pattern for horizontal scroll layouts
Exploring Variations
Once you have the core concept in place, there are four demo variations you can explore. Each one shows how different lag values and scroll-based interactions can influence the experience.
You can adjust which columns respond faster, or play with subtle scaling and transforms based on scroll velocity. Even small changes can shift the rhythm and tone of the layout in interesting ways. And don’t forget: changing the look of the grid itself, like the image ratio or gaps, will give this a whole different feel!
Now it’s your turn. Tweak it, break it, rebuild it, and make something cool.
I really hope you enjoy this effect! Thanks for checking by 🙂