This animation was born from a venture I labored on for Max & Tiber. The preliminary thought was easy: create a residing, structured picture grid, guided totally by the scroll.
Slightly than piling on results, the main focus was on rhythm, spacing, and development. A set scene. Scroll as the motive force. A composition that unfolds progressively, step-by-step.
However past the visible outcome, this venture grew to become an exploration of construction: how scroll may be mapped to time, how animation phases may be orchestrated, and the way format and movement may be designed collectively as a single system.
Technically, the animation depends on GSAP, ScrollTrigger, and Lenis.
On this tutorial, we are going to break down the animation step-by-step. From format construction to timeline orchestration, together with how clean scrolling integrates into the system.
The aim is to not reproduce the impact precisely, however to grasp an strategy: the way to construct a structured, readable, and managed scroll-driven animation.
What We’re Constructing
We’ll construct a scroll-driven animation primarily based on a minimal construction:
- A sticky block, used as a set scene
- A 12-image grid, organized in columns
- A essential timeline, orchestrating a number of inner timelines
Every half has a transparent function:
- The sticky block defines the visible stage
- The grid gives the animated materials
- The timeline coordinates how the whole lot evolves
Collectively, they type a managed surroundings the place movement may be exactly structured.
How Scroll Maps to Animation
Earlier than diving into the construction, it is very important perceive how the animation pertains to scroll.
The sticky part behaves like a set stage. Scrolling doesn’t transfer the scene, it advances time inside it. Because the consumer scrolls, the animation progresses by distinct phases:
Scroll progress → Visible state
0% – 45% Grid enters the scene (column reveal)
45% – 90% Grid expands and opens house (zoom and offsets)
90% – 95% Content material settles into focus (textual content and button seem)
95% – 100% Scene stabilizes
Every section corresponds to a phase of the principle timeline. Scroll place determines how far that timeline has progressed. As soon as this mannequin is evident, the animation turns into a composition of states reasonably than a group of results.
Now that the animation logic is outlined, we are able to construct the structural basis that helps it.
HTML Construction
The animation is constructed on a intentionally easy HTML construction. Every component has a transparent function, making each format and animation simpler to cause about. The primary animated block sits between common content material sections, making a pure scroll movement earlier than and after the sequence.
<!-- ... Earlier block -->
<part class="block block--main">
<div class="block__wrapper">
<div class="content material">
<h2 class="content__title">Sticky Grid Scroll</h2>
<p class="content__description">...</p>
<button class="content__button">...</button>
</div>
<div class="gallery">
<ul class="gallery__grid">
<li class="gallery__item">
<img class="gallery__image" src="../1.webp" alt="...">
</li>
<!-- Repeat to get 12 gadgets -->
</ul>
</div>
</div>
</part>
<!-- ... Subsequent block -->
Two distinct zones construction the scene:
- The textual content content material, centered within the viewport
- The gallery, which gives the visible materials for the animation
The .block acts because the scroll container. Its prolonged peak creates the temporal house wanted for the animation to unfold.
Inside it, .block__wrapper is sticky. This varieties the mounted scene the place the animation performs out whereas the consumer scrolls.
The .content material is separated from the gallery. This makes it potential to regulate its place, visibility, and interplay independently from the grid.
Lastly, the .gallery is constructed as a listing. Every picture is a person merchandise, which makes focusing on and animating components simple and predictable.
This easy hierarchy is intentional. It retains the format readable, isolates duties, and gives a clear basis for constructing the animation timelines.
CSS Setup
CSS defines the visible house earlier than any animation occurs. It establishes proportions, positioning, and structural stability. The aim right here isn’t ornamental styling, however preparation, constructing a stable, predictable surroundings the place movement can later unfold with management.
1. Fluid rem
The format makes use of a fluid rem system primarily based on the viewport width.
html {
font-size: calc(100vw / 1440);
}
physique {
font-size: 16rem;
}
At a width of 1440px, 1rem equals 1px. Above or beneath that width, all the interface scales proportionally. This strategy gives a number of advantages:
- Direct consistency between design and implementation
- Pure scaling with out complicated media queries
- Exact management over spacing and sizes
It’s a easy however deliberate technique. All the things scales collectively, preserving visible relationships throughout display sizes whereas protecting the format absolutely responsive
2. Major Block and Sticky Wrapper
The primary block is deliberately very tall. It creates the temporal house required for the animation to unfold.
.block.block--main {
peak: 425vh;
}
.block__wrapper {
place: sticky;
prime: 0;
padding: 0 24rem;
overflow: hidden;
}
The peak: 425vh isn’t arbitrary. It’s adjusted to match the variety of animation phases and their rhythm. The .block__wrapper stays pinned to the highest of the viewport, forming the mounted scene the place the whole lot occurs. overflow: hidden ensures visible cleanliness by masking components as they enter or depart the body.
3. Centered Content material
The textual content content material is centered within the viewport and acts as a visible anchor.
.content material {
place: relative;
show: flex;
flex-direction: column;
justify-content: heart;
align-items: heart;
width: 100%;
peak: 100vh;
text-align: heart;
z-index: 1;
}
The place and the z-index maintain it above the grid, guaranteeing readability even whereas the gallery strikes and transforms behind it. The content material stays secure, movement occurs round it.
4. The gallery
The gallery is positioned on the heart of the scene. It gives the visible materials that may later be animated.
.gallery {
place: absolute;
prime: 50%;
left: 50%;
rework: translate3d(-50%, -50%, 0);
width: 736rem;
}
The grid itself is structured into three columns.
.gallery__grid {
show: grid;
grid-template-columns: repeat(3, 1fr);
column-gap: 32rem;
row-gap: 40rem;
}
Every merchandise is completely sq. due to aspect-ratio.
.gallery__item {
width: 100%;
aspect-ratio: 1;
}
Every picture fills its container with out distortion.
.gallery__image {
width: 100%;
peak: 100%;
object-fit: cowl;
}
This grid is deliberately common. It gives a secure, virtually impartial base. Motion will later disturb this regularity in a managed approach.
JavaScript + GSAP + Lenis
This animation is totally scroll-driven. JavaScript is used right here to orchestrate motion, to not overwhelm it. The strategy depends on three components:
- GSAP, for exact movement
- ScrollTrigger, to bind animations to scroll
- Lenis, to realize clean and secure scrolling
All the things is structured round a single class. One scene. One duty. One orchestration layer.
1. Import and Register Plugins
We begin by importing the required dependencies.
import Lenis from "lenis"
import { gsap } from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import { preloadImages } from "./utils.js"
Then, we register the ScrollTrigger plugin.
gsap.registerPlugin(ScrollTrigger)
2. Initialize Lenis for Clean Scrolling
Lenis is used to clean out scrolling. The aim is to not rework the expertise, however to make it extra secure.
// Initialize clean scrolling utilizing Lenis and synchronize it with GSAP ScrollTrigger
operate initSmoothScrolling() {
// Create a brand new Lenis occasion for clean scrolling
const lenis = new Lenis({
lerp: 0.08,
wheelMultiplier: 1.4,
})
// Synchronize Lenis scrolling with GSAP's ScrollTrigger plugin
lenis.on("scroll", ScrollTrigger.replace)
// Add Lenis's requestAnimationFrame (raf) methodology to GSAP's ticker
// This ensures Lenis's clean scroll animation updates on every GSAP tick
gsap.ticker.add((time) => {
lenis.raf(time * 1000) // Convert time from seconds to milliseconds
})
// Disable lag smoothing in GSAP to stop any delay in scroll animations
gsap.ticker.lagSmoothing(0)
}
Scrolling turns into extra fluid, with out extreme inertia. Every motion stays tightly synchronized with the GSAP timelines. Right here, Lenis serves precision. It helps stop micro stutters and reinforces a way of continuity.
3. International Initialization
Earlier than diving into the category itself, we arrange the worldwide initialization.
// Preload pictures then initialize the whole lot
preloadImages().then(() => {
doc.physique.classList.take away("loading") // Take away loading state from physique
initSmoothScrolling() // Initialize clean scrolling
new StickyGridScroll() // Initialize grid animation
})
Pictures are preloaded. Clean scrolling is initialized. The scene can then be instantiated.
4. Class Construction
The complete animation is encapsulated inside a single class: StickyGridScroll. This class has one clear objective: to orchestrate the weather and their animations inside the principle block.
class StickyGridScroll {
constructor() {
this.getElements()
this.initContent()
this.groupItemsByColumn()
this.addParallaxOnScroll()
this.animateTitleOnScroll()
this.animateGridOnScroll()
}
}
The constructor is deliberately readable. Every methodology maps to a selected duty:
- Retrieving components
- Getting ready the preliminary state
- Organizing the grid
- Declaring the animations
We’ll now stroll by these steps, one after the other.
5. Retrieving Components
Earlier than any animation takes place, we isolate the weather we’d like. Nothing is animated “on the fly”. All the things is ready.
/**
* Choose and retailer the DOM components wanted for the animation
* @returns {void}
*/
getElements() {
this.block = doc.querySelector(".block--main")
if (this.block) {
this.wrapper = this.block.querySelector(".block__wrapper")
this.content material = this.block.querySelector(".content material")
this.title = this.block.querySelector(".content__title")
this.description = this.block.querySelector(".content__description")
this.button = this.block.querySelector(".content__button")
this.grid = this.block.querySelector(".gallery__grid")
this.gadgets = this.block.querySelectorAll(".gallery__item")
}
}
This strategy affords a number of advantages:
- The code stays readable
- Animations are simpler to take care of
- DOM references usually are not recalculated unnecessarily
At this level, all required components can be found. We are able to now put together their preliminary state earlier than introducing movement.
6. Getting ready the Content material
Earlier than triggering any motion, the content material must be positioned accurately. The thought is straightforward: begin from a impartial, managed state.
Hiding the Description and Button
The outline and the button shouldn’t seem instantly. They are going to be revealed later, as soon as the grid has settled into place. Interactions are additionally disabled. The content material exists within the DOM, however stays silent.
/**
* Initializes the visible state of the content material earlier than animations
* @returns {void}
*/
initContent() {
if (this.description && this.button) {
// Cover description and button
gsap.set([this.description, this.button], {
opacity: 0,
pointerEvents: "none"
})
}
// ...
}
Dynamically Centering the Title
The title, then again, must be visually centered throughout the viewport. Slightly than counting on a set worth, we calculate its offset dynamically.
/**
* Initializes the visible state of the content material earlier than animations
* @returns {void}
*/
initContent() {
// ...
if (this.content material && this.title) {
// Calculate what number of pixels are wanted to vertically heart the title inside its container
const dy = (this.content material.offsetHeight - this.title.offsetHeight) / 2
// Convert this pixel offset right into a proportion of the container peak
this.titleOffsetY = (dy / this.content material.offsetHeight) * 100
// Apply the vertical positioning utilizing percent-based rework
gsap.set(this.title, { yPercent: this.titleOffsetY })
}
}
The logic is simple:
- Measure the accessible house across the title
- Decide the offset required to heart it vertically
- Convert that offset right into a proportion
This selection issues. By utilizing yPercent as an alternative of pixels, the positioning stays fluid and resilient.
At this stage, the title is centered, the outline and button are hidden. The scene is prepared. All that’s left is to arrange the visible materials: the grid.
7. Grouping Objects into Columns
The grid is made up of 12 pictures. To create extra nuanced and assorted animations, we arrange them into three columns. This grouping varieties the inspiration for the whole lot that follows. It prepares the scene for the principle timeline, which can orchestrate the reveal, the zoom, and the content material toggle.
/**
* Group grid gadgets into a set variety of columns
* @returns {void}
*/
groupItemsByColumn() {
this.numColumns = 3
// Initialize an array for every column
this.columns = Array.from({ size: this.numColumns }, () => [])
// Distribute grid gadgets into column buckets
this.gadgets.forEach((merchandise, index) => {
this.columns[index % this.numColumns].push(merchandise)
})
}
8. The Animations
All animations are pushed by GSAP and ScrollTrigger. Scroll turns into a steady timeline, and every inner timeline contributes to the visible development.
We’ll break down every step, beginning with the principle timeline, which acts because the structural spine of all the sequence.
Major Timeline (Scroll-Pushed)
The primary timeline is the center of the scene. It’s accountable for:
- Orchestrating the grid reveal
- Driving the zoom
- Synchronizing the looks of the textual content content material
/**
* Animate the grid primarily based on scroll place
* Combines grid reveal, grid zoom, and content material toggle in a scroll-driven timeline
*
* @returns {void}
*/
animateGridOnScroll() {
// Create a scroll-driven timeline
const timeline = gsap.timeline({
scrollTrigger: {
set off: this.block,
begin: "prime 25%", // Begin when prime of block hits 25% of viewport
finish: "backside backside", // Finish when backside of block hits backside of viewport
scrub: true, // Clean animation primarily based on scroll place
},
})
timeline
// Add grid reveal animation
.add(this.gridRevealTimeline())
// Add grid zoom animation, overlapping earlier animation by 0.6 seconds
.add(this.gridZoomTimeline(), "-=0.6")
// Toggle content material visibility primarily based on scroll path, overlapping earlier animation by 0.32 seconds
.add(() => this.toggleContent(timeline.scrollTrigger.path === 1), "-=0.32")
}
Why This Timeline Issues?
- It centralizes all animations
- It defines the length and rhythm of the scroll
- It creates rigorously timed overlaps, protecting movement pure
- Inner timelines (reveal, zoom, toggle) turn out to be modular constructing blocks.
That is the principle rating.
Grid Reveal Timeline
Every column of the grid is revealed from the highest or backside, with a delicate stagger.
- Even columns animate from the highest
- Odd columns animate from the underside
- The gap is calculated dynamically primarily based on the viewport peak
This step creates the primary noticeable motion, because the grid unfolds easily into the scene.
/**
* Create a GSAP timeline to disclose the grid gadgets with vertical animation
* Every column strikes from prime or backside, with staggered timing
*
* @param {Array} columns - Array of columns, every containing DOM components of the grid
* @returns {gsap.core.Timeline} - The timeline for the grid reveal animation
*/
gridRevealTimeline(columns = this.columns) {
// Create a timeline
const timeline = gsap.timeline()
const wh = window.innerHeight
// Calculate the space to start out grid absolutely exterior the viewport (above or beneath)
const dy = wh - (wh - this.grid.offsetHeight) / 2
columns.forEach((column, colIndex) => {
// Decide the path: columns with even index transfer from prime, odd from backside
const fromTop = colIndex % 2 === 0
// Animate all gadgets within the column
timeline.from(column, {
y: dy * (fromTop ? -1 : 1), // Begin above or beneath the viewport primarily based on column index
stagger: {
every: 0.06, // Stagger the animation throughout the column: 60ms between every merchandise's animation
from: fromTop ? "finish" : "begin", // Animate from backside if shifting down, prime if shifting up
},
ease: "power1.inOut",
}, "grid-reveal") // Label to synchronize animations throughout columns
})
return timeline
}
Grid Zoom Timeline
The zoom enhances the visible dynamics:
- The complete grid is barely enlarged (
scale: 2.05) - The facet columns transfer horizontally outward
- Objects within the central column shift vertically
This opening impact provides depth to the scene and permits the content material to breathe.
/**
* Create a GSAP timeline to zoom the grid
* Lateral columns transfer horizontally, central column gadgets transfer vertically
*
* @param {Array} columns - Array of columns, every containing DOM components of the grid
* @returns {gsap.core.Timeline} - The timeline for the grid zoom animation
*/
gridZoomTimeline(columns = this.columns) {
// Create a timeline with default length and easing for all tweens
const timeline = gsap.timeline({ defaults: { length: 1, ease: "power3.inOut" } })
// Zoom all the grid
timeline.to(this.grid, { scale: 2.05 })
// Transfer lateral columns horizontally
timeline.to(columns[0], { xPercent: -40 }, "<") // Left column strikes left
timeline.to(columns[2], { xPercent: 40 }, "<") // Proper column strikes proper
// Animate central column vertically
timeline.to(columns[1], {
// Objects above the midpoint transfer up, beneath transfer down
yPercent: (index) => (index < Math.ground(columns[1].size / 2) ? -1 : 1) * 40,
length: 0.5,
ease: "power1.inOut",
}, "-=0.5") // Begin barely earlier than earlier animation ends for overlap
return timeline
}
Toggle Content material
The primary timeline determines when the textual content content material seems:
- The title slides into its ultimate place
- The outline and button turn out to be seen and interactive
- The animation is delicate, deliberate, and clean
Content material doesn’t seem arbitrarily, it emerges when house has been created for it.
/**
* Toggle the visibility of content material components (title, description, button) with animations
*
* @param {boolean} isVisible - Whether or not the content material must be seen
* @returns {void}
*/
toggleContent(isVisible = true) {
if (!this.title || !this.description || !this.button) {
return
}
// Create a timeline
gsap.timeline({ defaults: { overwrite: true } })
// Animate the title's vertical place
.to(this.title, {
yPercent: isVisible ? 0 : this.titleOffsetY, // Slide up or return to preliminary offset
length: 0.7,
ease: "power2.inOut",
})
// Animate description and button opacity and pointer occasions
.to([this.description, this.button], {
opacity: isVisible ? 1 : 0,
length: 0.4,
ease: `power1.${isVisible ? "inOut" : "out"}`,
pointerEvents: isVisible ? "all" : "none",
}, isVisible ? "-=90%" : "<") // Overlap with earlier tween when exhibiting
}
Add Parallax On Scroll
The wrapper undergoes a slight vertical shift, creating the phantasm of mounted content material.
/**
* Apply a parallax impact to the wrapper when scrolling
* @returns {void}
*/
addParallaxOnScroll() {
if (!this.block || !this.wrapper) {
return
}
// Create a scroll-driven timeline
// Animate the wrapper vertically primarily based on scroll place
gsap.from(this.wrapper, {
yPercent: -100,
ease: "none",
scrollTrigger: {
set off: this.block,
begin: "prime backside", // Begin when prime of block hits backside of viewport
finish: "prime prime", // Finish when prime of block hits prime of viewport
scrub: true, // Clean animation primarily based on scroll place
},
})
}
Animate Title On Scroll
Lastly, the title receives a delicate fade-in:
- It turns into seen on the key second within the timeline
- The reader’s gaze is of course guided to the content material
- The sequence feels full and coherent
/**
* Animate the title component when the block scrolls into view
* @returns {void}
*/
animateTitleOnScroll() {
if (!this.block || !this.title) {
return
}
// Create a scroll-driven timeline
// Animate the title's opacity when the block reaches 57% of the viewport peak
gsap.from(this.title, {
opacity: 0,
length: 0.7,
ease: "power1.out",
scrollTrigger: {
set off: this.block,
begin: "prime 57%", // Begin when prime of block hits 57% of viewport
toggleActions: "play none none reset", // Play on enter, reset on depart again
},
})
}
Now, all animations are orchestrated. Scroll turns into the motive force, and the scene unfolds in a fluid, managed, and stylish method.
And That’s It
This animation demonstrates how scroll can turn out to be a software for visible storytelling. Each motion and transition is designed to be clear, fluid, and structured.
Behind the impact lies a easy however highly effective strategy: a minimal HTML construction, a secure CSS format, and a essential timeline orchestrating movement with precision. Every animation acts as a constructing block, assembled right into a coherent development.
Extra importantly, this technique is designed to scale. You possibly can alter the rhythm, change the format, introduce new animation phases, or reorganize the timeline totally, whereas protecting the identical underlying logic.
Now, it’s your flip. Experiment, differ the rhythms, enrich the composition. At all times remember precision, readability, and magnificence.
I sincerely hope you’ll get pleasure from this impact and discover it inspiring. Thanks for taking the time to learn this tutorial. I’d be delighted to listen to your suggestions. You possibly can attain me out through Instagram, LinkedIn, or e-mail.


