Scroll-Scaling Editorial Image Grid
Goal
Build a full-page scroll gallery: a dark editorial grid of portraits arranged in 10 four-column rows, where each row's images scale up from 0 to 1 (from a corner transform-origin) as the row scrolls into view, then the row pins and its images scale back down from 1 to 0 as it scrolls out — all driven by scrubbed ScrollTriggers and Lenis smooth scrolling.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis for smooth scrolling.
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
Run everything inside a DOMContentLoaded listener.
Layout / HTML
Three sections in <body>:
<section class="intro">— an<h1>reading "Design that Captivates" and a<p>reading "( Explore Below )".<section class="work">— 10<div class="row">elements. Every row contains exactly 4<div class="col">elements. Some cols are empty; others contain one<div class="img" data-origin="left|right"><img src="..." alt="" /></div>. There are 17 images total, distributed like this (cols listed 1→4,L=data-origin="left",R=data-origin="right",—= empty col):- Row 1:
R(img 1),—,L(img 2),— - Row 2:
—,L(img 3),—,— - Row 3:
R(img 4),—,—,L(img 5) - Row 4:
—,L(img 6),R(img 7),— - Row 5:
L(img 8),—,—,L(img 9) - Row 6:
—,—,L(img 10),— - Row 7:
—,L(img 11),—,L(img 12) - Row 8:
R(img 13),—,L(img 14),— - Row 9:
—,L(img 15),—,— - Row 10:
R(img 16),—,—,L(img 17) <section class="outro">— a single<p>reading "( Return to the Beginning )".
Styling
- Universal reset:
* { margin: 0; padding: 0; box-sizing: border-box; }. body:font-family: "TWK Lausanne", monospace;(a neutral grotesque sans; monospace fallback is fine).img:width: 100%; height: 100%; object-fit: cover;.h1:text-transform: uppercase; text-align: center; font-size: 10vw; font-weight: 400;.p:text-transform: uppercase; font-family: "Akkurat Mono"; font-size: 13px;(small mono caption style)..intro, .outro:position: relative; width: 100vw; height: 100vh; background-color: #101214; color: #fff;flex column, centered both axes,gap: 4em; overflow: hidden;..work:position: relative; width: 100vw; overflow: hidden; background-color: #1a1d20;(slightly lighter charcoal than intro/outro)..row:width: 100vw; display: flex;..col:flex: 1; aspect-ratio: 1;— so each col is a 25vw × 25vw square and each row is 25vw tall..img:position: relative; width: 100%; height: 100%; will-change: transform;.- Transform origins are critical for the effect (images grow out of a top corner):
.img[data-origin="left"] { transform-origin: 0% 0%; }.img[data-origin="right"] { transform-origin: 100% 0%; }
GSAP effect (be precise)
Lenis + ScrollTrigger wiring
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
Setup
- Safety pass: for every
.imgwithout adata-originattribute, assign one alternating by index —index % 2 === 0→"left", otherwise"right". - Initial state:
gsap.set(".img", { scale: 0, force3D: true });— all images start invisible at scale 0.
Per-row ScrollTriggers
Loop over all .row elements (rows.forEach((row, index) => ...)). Skip rows with no .img children. For each qualifying row, set row.id = "row-" + index and create three ScrollTriggers (give them ids so they can look each other up):
1. Scale-in trigger (id: "scaleIn-" + index):
trigger: row,start: "top bottom",end: "bottom bottom-=10%",scrub: 1,invalidateOnRefresh: true.- No tween — drive it manually in
onUpdate(self): only whenself.isActive, compute
``js const easedProgress = Math.min(1, self.progress * 1.2); const scaleValue = gsap.utils.interpolate(0, 1, easedProgress); ` and gsap.set every image in the row to { scale: scaleValue, force3D: true }. This 1.2 multiplier makes images reach full size at 83% of the trigger distance. Additionally, when self.progress > 0.95, hard-snap the row's images to { scale: 1, force3D: true }`.
onLeave: snap the row's images to{ scale: 1, force3D: true }.
2. Scale-out trigger with pin (id: "scaleOut-" + index):
trigger: row,start: "top top",end: "bottom top",pin: true,pinSpacing: false,scrub: 1,invalidateOnRefresh: true. (WithpinSpacing: falseeach row pins at the top of the viewport while the next row slides up over it.)onEnter: set the row's images to{ scale: 1, force3D: true }.onUpdate(self):- If
self.isActive:const scale = gsap.utils.interpolate(1, 0, self.progress);andgsap.seteach image to{ scale, force3D: true, clearProps: self.progress === 1 ? "scale" : "" }(clear the inline scale only when fully done). - Else: if
self.scroll() < self.start(viewport is above the row), reset the images to{ scale: 1, force3D: true }.
3. Marker/guard trigger (id: "marker-" + index):
trigger: row,start: "bottom bottom",end: "top top", no scrub, no pin.- In
onEnter,onLeave, andonEnterBack(same body in all three): look up the scale-out trigger viaScrollTrigger.getById("scaleOut-" + index); if it exists and itsprogress === 0, force the row's images to{ scale: 1, force3D: true }. This guard keeps rows fully visible in the zone between "finished scaling in" and "started scaling out", including when scrolling back up.
Resize
window.addEventListener("resize", () => { ScrollTrigger.refresh(true); });
Assets / images
17 moody editorial fashion portraits (a mix of color — deep reds, teals, warm skin tones — and high-contrast black-and-white studio shots: close-up faces, silhouettes, full-body figures in sculptural garments). Any aspect ratio works because each is cropped into a 1:1 square cell via object-fit: cover; portrait-oriented (2:3-ish) crops look best. Name them img1.jpeg … img17.jpeg and place them in the HTML order given in the layout table above.
Behavior notes
- This is a page-level component: Lenis takes over scrolling for the whole page.
- The net choreography: images of a row grow from 0→1 out of their top-left or top-right corner while the row travels up the viewport, the row then sticks to the top of the screen (pinned, no pin spacing) and its images shrink 1→0 as the following row covers it — producing a continuous bloom-and-collapse rhythm through all 10 rows.
- The intro and outro sections have no animation; they simply bookend the grid with full-viewport dark panels.
- All scaling is done through
gsap.setinsideonUpdatecallbacks (not tweens), so motion smoothness comes entirely fromscrub: 1+ Lenis interpolation. - Use
force3D: trueon every set to keep the transforms on the GPU;will-change: transformis already on.img.