Introdução ao Projeto de Animação de Grid com Scroll
Desafio e Objetivos
O objetivo deste projeto é criar uma animação de grid que seja controlada pelo scroll, utilizando tecnologias como GSAP, ScrollTrigger e Lenis. A animação deve ser fluida, controlada e elegante, com movimentos e transições claros e precisos.
Arquitetura do Projeto
O projeto consiste em uma estrutura HTML simples, com um bloco principal que contém o grid de imagens. O CSS define a aparência e a posição dos elementos, enquanto o JavaScript utiliza GSAP, ScrollTrigger e Lenis para criar a animação controlada pelo scroll.
HTML Structure
<section class="block block--main">
<div class="block__wrapper">
<div class="content">
<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>
<li class="gallery__item">
<img class="gallery__image" src="../2.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../3.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../4.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../5.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../6.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../7.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../8.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../9.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../10.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../11.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../12.webp" alt="..." />
</li>
</ul>
</div>
</div>
</section>
CSS Setup
html {
font-size: calc(100vw / 1440);
}
body {
font-size: 1.6rem;
}
.block.block--main {
height: 425vh;
}
.block__wrapper {
position: sticky;
top: 0;
padding: 0 24rem;
overflow: hidden;
}
.content {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100vh;
text-align: center;
z-index: 1;
}
.gallery {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 73.6rem;
}
.gallery__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
column-gap: 3.2rem;
row-gap: 4rem;
}
.gallery__item {
width: 100%;
aspect-ratio: 1;
}
.gallery__image {
width: 100%;
height: 100%;
object-fit: cover;
}
JavaScript + GSAP + Lenis
O JavaScript utiliza GSAP, ScrollTrigger e Lenis para criar a animação controlada pelo scroll. A animação é dividida em várias partes, incluindo a revelação do grid, o zoom do grid e a troca de conteúdo.
import Lenis from "lenis"
import { gsap } from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import { preloadImages } from "./utils.js"
gsap.registerPlugin(ScrollTrigger)
function initSmoothScrolling() {
const lenis = new Lenis({
lerp: 0.08,
wheelMultiplier: 1.4,
})
lenis.on("scroll", ScrollTrigger.update)
gsap.ticker.add((time) => {
lenis.raf(time * 1000)
})
gsap.ticker.lagSmoothing(0)
}
preloadImages().then(() => {
document.body.classList.remove("loading")
initSmoothScrolling()
new StickyGridScroll()
})
class StickyGridScroll {
constructor() {
this.getElements()
this.initContent()
this.groupItemsByColumn()
this.addParallaxOnScroll()
this.animateTitleOnScroll()
this.animateGridOnScroll()
}
}
StickyGridScroll.prototype.getElements = function() {
this.block = document.querySelector(".block--main")
if (this.block) {
this.wrapper = this.block.querySelector(".block__wrapper")
this.content = this.block.querySelector(".content")
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.items = this.block.querySelectorAll(".gallery__item")
}
}
StickyGridScroll.prototype.initContent = function() {
if (this.description && this.button) {
gsap.set([this.description, this.button], {
opacity: 0,
pointerEvents: "none",
})
}
if (this.content && this.title) {
const dy = (this.content.offsetHeight - this.title.offsetHeight) / 2
this.titleOffsetY = (dy / this.content.offsetHeight) * 100
gsap.set(this.title, { yPercent: this.titleOffsetY })
}
}
StickyGridScroll.prototype.groupItemsByColumn = function() {
this.numColumns = 3
this.columns = Array.from({ length: this.numColumns }, () => [])
this.items.forEach((item, index) => {
this.columns[index % this.numColumns].push(item)
})
}
StickyGridScroll.prototype.animateGridOnScroll = function() {
const timeline = gsap.timeline({
scrollTrigger: {
trigger: this.block,
start: "top 25%",
end: "bottom bottom",
scrub: true,
},
})
timeline
.add(this.gridRevealTimeline())
.add(this.gridZoomTimeline(), "-=0.6")
.add(() => this.toggleContent(timeline.scrollTrigger.direction === 1), "-=0.32")
}
StickyGridScroll.prototype.gridRevealTimeline = function(columns = this.columns) {
const timeline = gsap.timeline()
const wh = window.innerHeight
const dy = wh - (wh - this.grid.offsetHeight) / 2
columns.forEach((column, colIndex) => {
const fromTop = colIndex % 2 === 0
timeline.from(column, {
y: dy * (fromTop ? -1 : 1),
stagger: {
each: 0.06,
from: fromTop ? "end" : "start",
},
ease: "power1.inOut",
}, "grid-reveal")
})
return timeline
}
StickyGridScroll.prototype.gridZoomTimeline = function(columns = this.columns) {
const timeline = gsap.timeline({ defaults: { duration: 1, ease: "power3.inOut" } })
timeline
.to(this.grid, { scale: 2.05 })
.to(columns[0], { xPercent: -40 }, "<")
.to(columns[2], { xPercent: 40 }, "<")
.to(columns[1], {
yPercent: (index) => (index < Math.floor(columns[1].length / 2) ? -1 : 1) * 40,
duration: 0.5,
ease: "power1.inOut",
}, "-=0.5")
return timeline
}
StickyGridScroll.prototype.toggleContent = function(isVisible = true) {
if (!this.title || !this.description || !this.button) {
return
}
gsap.timeline({ defaults: { overwrite: true } })
.to(this.title, {
yPercent: isVisible ? 0 : this.titleOffsetY,
duration: 0.7,
ease: "power2.inOut",
})
.to([this.description, this.button], {
opacity: isVisible ? 1 : 0,
duration: 0.4,
ease: `power1.${isVisible ? "inOut" : "out"}`,
pointerEvents: isVisible ? "all" : "none",
}, isVisible ? "-=0.9" : "<")
}
StickyGridScroll.prototype.addParallaxOnScroll = function() {
if (!this.block || !this.wrapper) {
return
}
gsap.from(this.wrapper, {
yPercent: -100,
ease: "none",
scrollTrigger: {
trigger: this.block,
start: "top bottom",
end: "top top",
scrub: true,
},
})
}
StickyGridScroll.prototype.animateTitleOnScroll = function() {
if (!this.block || !this.title) {
return
}
gsap.from(this.title, {
opacity: 0,
duration: 0.7,
ease: "power1.out",
scrollTrigger: {
trigger: this.block,
start: "top 57%",
toggleActions: "play none none reset",
},
})
}
new StickyGridScroll()
Estrutura do Projeto
Classe StickyGridScroll
class StickyGridScroll {
constructor() {
this.getElements()
this.initContent()
this.groupItemsByColumn()
this.addParallaxOnScroll()
this.animateTitleOnScroll()
this.animateGridOnScroll()
}
}
Métodos da Classe
getElements
StickyGridScroll.prototype.getElements = function() {
this.block = document.querySelector(".block--main")
if (this.block) {
this.wrapper = this.block.querySelector(".block__wrapper")
this.content = this.block.querySelector(".content")
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.items = this.block.querySelectorAll(".gallery__item")
}
}
initContent
StickyGridScroll.prototype.initContent = function() {
if (this.description && this.button) {
gsap.set([this.description, this.button], {
opacity: 0,
pointerEvents: "none",
})
}
if (this.content && this.title) {
const dy = (this.content.offsetHeight - this.title.offsetHeight) / 2
this.titleOffsetY = (dy / this.content.offsetHeight) * 100
gsap.set(this.title, { yPercent: this.titleOffsetY })
}
}
groupItemsByColumn
StickyGridScroll.prototype.groupItemsByColumn = function() {
this.numColumns = 3
this.columns = Array.from({ length: this.numColumns }, () => [])
this.items.forEach((item, index) => {
this.columns[index % this.numColumns].push(item)
})
}
Animações
animateGridOnScroll
StickyGridScroll.prototype.animateGridOnScroll = function() {
const timeline = gsap.timeline({
scrollTrigger: {
trigger: this.block,
start: "top 25%",
end: "bottom bottom",
scrub: true,
},
})
timeline
.add(this.gridRevealTimeline())
.add(this.gridZoomTimeline(), "-=0.6")
.add(() => this.toggleContent(timeline.scrollTrigger.direction === 1), "-=0.32")
}
gridRevealTimeline
StickyGridScroll.prototype.gridRevealTimeline = function(columns = this.columns) {
const timeline = gsap.timeline()
const wh = window.innerHeight
const dy = wh - (wh - this.grid.offsetHeight) / 2
columns.forEach((column, colIndex) => {
const fromTop = colIndex % 2 === 0
timeline.from(column, {
y: dy * (fromTop ? -1 : 1),
stagger: {
each: 0.06,
from: fromTop ? "end" : "start",
},
ease: "power1.inOut",
}, "grid-reveal")
})
return timeline
}
gridZoomTimeline
StickyGridScroll.prototype.gridZoomTimeline = function(columns = this.columns) {
const timeline = gsap.timeline({ defaults: { duration: 1, ease: "power3.inOut" } })
timeline
.to(this.grid, { scale: 2.05 })
.to(columns[0], { xPercent: -40 }, "<")
.to(columns[2], { xPercent: 40 }, "<")
.to(columns[1], {
yPercent: (index) => (index < Math.floor(columns[1].length / 2) ? -1 : 1) * 40,
duration: 0.5,
ease: "power1.inOut",
}, "-=0.5")
return timeline
}
toggleContent
StickyGridScroll.prototype.toggleContent = function(isVisible = true) {
if (!this.title || !this.description || !this.button) {
return
}
gsap.timeline({ defaults: { overwrite: true } })
.to(this.title, {
yPercent: isVisible ? 0 : this.titleOffsetY,
duration: 0.7,
ease: "power2.inOut",
})
.to([this.description, this.button], {
opacity: isVisible ? 1 : 0,
duration: 0.4,
ease: `power1.${isVisible ? "inOut" : "out"}`,
pointerEvents: isVisible ? "all" : "none",
}, isVisible ? "-=0.9" : "<")
}
HTML e CSS
Estrutura HTML Simples
<section class="block block--main">
<div class="block__wrapper">
<div class="content">
<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>
<li class="gallery__item">
<img class="gallery__image" src="../2.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../3.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../4.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../5.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../6.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../7.webp" alt="..." />
</li>
<li class="gallery__item">
<img class="gallery__image" src="../8.webp" alt="..." />
</li>
<li class="gallery__item">
<