All components

Fiddle Digital Scroll Animation

GSAP animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A grid of editorial portraits scattered across a multi-column layout where each row's images scale up from 0 to full size as the row scrolls into the viewport, then the row pins and its images scale back down to 0 as it scrolls out. Per-row ScrollTrigger pairs (a scrub scaleIn from 'top bottom' to 'bottom bottom-=10%' and a pinned scaleOut from 'top top' to 'bottom top') drive gsap.set scale via onUpdate, with transform-origin swinging left/right per data-origin, all Lenis-smoothed.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll scrub pin scale grid image-reveal gsap lenis editorial

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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>:

  1. <section class="intro"> — an <h1> reading "Design that Captivates" and a <p> reading "( Explore Below )".
  2. <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):
  3. Row 1: R (img 1), , L (img 2),
  4. Row 2: , L (img 3), ,
  5. Row 3: R (img 4), , , L (img 5)
  6. Row 4: , L (img 6), R (img 7),
  7. Row 5: L (img 8), , , L (img 9)
  8. Row 6: , , L (img 10),
  9. Row 7: , L (img 11), , L (img 12)
  10. Row 8: R (img 13), , L (img 14),
  11. Row 9: , L (img 15), ,
  12. Row 10: R (img 16), , , L (img 17)
  13. <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 .img without a data-origin attribute, 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 when self.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. (With pinSpacing: false each 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); and gsap.set each 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, and onEnterBack (same body in all three): look up the scale-out trigger via ScrollTrigger.getById("scaleOut-" + index); if it exists and its progress === 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.jpegimg17.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.set inside onUpdate callbacks (not tweens), so motion smoothness comes entirely from scrub: 1 + Lenis interpolation.
  • Use force3D: true on every set to keep the transforms on the GPU; will-change: transform is already on .img.