All components

Gregory Lalle Awwwards Rebuild

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

Open live demo ↗ Raw prompt (.md)

What it does

A scroll-driven vertical portfolio gallery where a fixed project index number reveals and pushes up via masked GSAP digit tweens as each section enters the viewport center. ScrollTrigger drives a side progress bar, a moving project-name indicator, and a bottom-right preview image that syncs to whichever gallery image is currently centered, with Lenis smooth scrolling.

How it's built

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

scroll gallery gsap scrolltrigger lenis portfolio editorial masked-text minimal

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

Editorial Portfolio Gallery — Fixed Project Number that Sticks at Center then Peels Upward as the Next Section Pushes it Out

Goal

Build a tall, scroll-driven vertical portfolio. Eight full-viewport project sections scroll past under smooth (Lenis) scrolling. On the left edge of each section a large two-digit project number (01–08) sits pinned; when a section reaches the vertical center of the screen its number switches from absolutely-positioned to position:fixed at top:50vh so it hangs at screen-center, and the moment the next section climbs into view the number peels upward out of a fixed-height mask — the mask, an inner digit-wrapper, and each of the two digit glyphs all translate y:-80 with staggered durations and delays so the number exits in layers. Meanwhile a slim right-edge progress bar fills top→bottom, a small project-name list on the left has a triangular indicator that hops down one row per section while the current name goes black, and a bottom-right preview thumbnail swaps to whichever gallery image is currently crossing the viewport's vertical center. The star effect is the layered, velocity-aware masked-number push-up driven entirely from a single per-frame ScrollTrigger.onUpdate reading getBoundingClientRect().

Tech

Vanilla HTML/CSS/JS with ES module imports, fresh Vite project. Install and import from npm:

  • gsap (3.x) plus the plugin ScrollTrigger only.
  • lenis — smooth scroll.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger);

Wrap the whole script in document.addEventListener("DOMContentLoaded", () => { … }). No SplitText, no CustomEase, no canvas/WebGL. The digit-splitting and the image-grid population are done by hand in JS (see below), so the HTML you author does not contain the digit spans or the gallery <img>s — JS injects them.

Layout / HTML

Class names are load-bearing (JS/CSS query them). Author the gallery .images containers empty — JS fills them.

<nav>
  <a href="#">Index</a>
  <a href="#">Watch Showreel</a>
</nav>
<div class="nav-items">
  <a href="#">Work,</a>
  <a href="#">About,</a>
  <a href="#">Contact</a>
</div>

<div class="whitespace w-1"></div>

<div class="gallery">
  <!-- Repeat this .project block 8 times, numbers 01 → 08 -->
  <div class="project">
    <div class="index">
      <div class="mask"><h1>01</h1></div>
    </div>
    <div class="images"></div>   <!-- left empty; JS injects 6 .img each -->
  </div>
  <!-- …02 … 08 … -->
</div>

<div class="whitespace w-2"></div>

<div class="project-names">
  <div class="indicator">
    <div class="symbol"></div>
  </div>
  <div class="name active"><p>Atlas Studio</p></div>
  <div class="name"><p>Nimbus</p></div>
  <div class="name"><p>Solara</p></div>
  <div class="name"><p>Quantum</p></div>
  <div class="name"><p>Echo Labs</p></div>
  <div class="name"><p>Vesper</p></div>
  <div class="name"><p>Axon</p></div>
  <div class="name"><p>Horizon</p></div>
</div>

<div class="preview-img">
  <img src="/img1.jpg" alt="" />
</div>

<div class="progress-bar"></div>

<div class="footer"><p>Portfolio 2024</p></div>

<script type="module" src="./script.js"></script>

Notes:

  • Exactly 8 .project blocks; each .mask h1 holds a two-character number as text (0108) — the split logic assumes exactly two glyphs.
  • There are 8 .name rows matching the 8 projects; the first row carries .active. Names are neutral invented studio names (no real brands).
  • The .preview-img's initial <img> src is the first gallery image; JS will overwrite .src on scroll.

Styling

Font: Circular Std, weight 500, applied to everything. (Circular Std is a licensed geometric sans; if unavailable, fall back to any clean geometric sans such as system-ui/Helvetica/Inter — the layout does not depend on the exact face.) Everything is black text on a plain white page (no background set on body, so default white #fff).

Reset & type:

  • ::-webkit-scrollbar { display:none; } (hide native scrollbar).
  • * { margin:0; padding:0; box-sizing:border-box; }
  • img { width:100%; height:100%; object-fit:cover; }
  • h1 { font-family:"Circular Std"; font-weight:500; font-size:58px; }
  • p, a { text-decoration:none; text-transform:uppercase; font-family:"Circular Std"; font-size:14px; font-weight:500; color:#000; transition:color 0.3s; }

Fixed chrome (all corners pinned to the viewport):

  • nav { position:fixed; top:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; } — "Index" left, "Watch Showreel" right.
  • .nav-items { position:fixed; top:2em; left:40%; display:flex; gap:0.25em; } — three inline links.
  • nav a, .nav-items a { text-transform:none; } (override the global uppercase for the nav links only).
  • .footer p { position:fixed; bottom:2em; left:40%; text-transform:none; }

A left:40% vertical guide aligns .nav-items, .project-names, and the footer to the same column.

Spacers (pure scroll runway so the first/last project can travel through center):

  • .whitespace.w-1 { width:100vw; height:50vh; }
  • .whitespace.w-2 { width:100vw; height:45vh; }

Gallery grid:

  • .gallery { position:relative; width:100%; display:flex; flex-direction:column; gap:20em; } — big 20em vertical gap between projects.
  • .project { position:relative; width:100vw; height:100vh; display:flex; } — each section is one full viewport, a flex row.
  • .index { flex:1; padding-left:2em; height:0; } — left rail, 1/7 of the row width, zero height (it only anchors the absolutely-positioned mask; it must not consume vertical space).
  • .images { flex:6; display:flex; flex-direction:column; gap:1em; height:100vh; } — right column, 6/7 width, full height, holding 6 stacked images with 1em gaps.
  • .img { flex:1; width:200px; background-color:lightgray; overflow:hidden; } — each image cell splits the column height equally (flex:1), clips overflow.
  • .img img { opacity:0.9; } — gallery images sit at 90% opacity.

The masked number (critical geometry):

  • .index .mask { position:absolute; top:0; left:2em; height:70px; overflow:hidden; will-change:transform; } — the mask is a 70px-tall clipping window at the top-left of each .project (its offset parent is .project, which is relative). overflow:hidden is what makes the digit disappear as it slides up.
  • .index .mask h1 { position:relative; will-change:transform; }
  • .index .mask h1 span { position:relative; display:inline-block; will-change:transform; }
  • .digit-wrapper, .digit-wrapper span { display:inline-block; position:relative; will-change:transform; } — the JS-injected wrapper and its two digit spans are inline-blocks so each can be transformed independently.

Left project-name list + indicator:

  • .project-names { position:fixed; width:200px; top:50vh; left:40%; transform:translateX(0%); } — fixed at vertical center, left column.
  • .indicator { position:absolute; top:0; right:0; width:18px; height:18px; display:flex; justify-content:center; align-items:center; will-change:transform; } — a small box at the right of the name list that GSAP slides down.
  • .symbol { width:12px; height:12px; background-color:#000; clip-path:polygon(0 50%, 100% 100%, 100% 0); } — a left-pointing black triangle.
  • .name { height:18px; } — each row is exactly 18px tall (matches the indicator's 18px step).
  • .name p { color:gray; } and .name.active p { color:#000; } — inactive names are gray, the active one is black.

Bottom-right preview + progress bar:

  • .preview-img { position:fixed; bottom:2em; right:2em; max-width:40vw; height:calc(50vh - 2em); opacity:0.9; } — a floating preview panel bottom-right, up to 40vw wide, roughly half-viewport tall, at 90% opacity.
  • .progress-bar { position:fixed; top:0; right:0; width:8px; height:100vh; background-color:#000; transform-origin:top; transform:scaleY(0); } — an 8px black bar on the far right, scaled from the top, starting collapsed (scaleY(0)).

GSAP effect (be exhaustive — this is the point)

Smooth-scroll wiring (Lenis + GSAP ticker)
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => {
  lenis.raf(time * 750);
});
gsap.ticker.lagSmoothing(0);

Note the multiplier is **time * 750** (not the usual 1000) — a deliberately slower, heavier Lenis feel. Lenis pumps ScrollTrigger.update on every scroll; lag smoothing off.

Up-front state (declared before any ScrollTrigger to dodge a TDZ)

A pinned trigger's onUpdate can fire during ScrollTrigger.refresh() on creation, before later declarations run, so declare these first:

let scrollVelocity = 0, lastScrollTop = 0, activeIndex = -1;
Manual digit split — splitTextIntoSpans(".mask h1")

For every .mask h1, read its two characters and rebuild its innerHTML:

function splitTextIntoSpans(selector) {
  document.querySelectorAll(selector).forEach((el) => {
    const [firstDigit, secondDigit] = el.innerText;   // e.g. "0","1"
    el.innerHTML = `
      <div class="digit-wrapper">
        <span class="first">${firstDigit}</span><span class="second">${secondDigit}</span>
      </div>`;
  });
}
splitTextIntoSpans(".mask h1");

Result nesting inside each mask: h1 > .digit-wrapper > span.first + span.second. These four layers (mask, .digit-wrapper, .first, .second) are what the push-up animates independently.

Manual gallery population — populateGallery()
const imagesPerProject = 6;
const totalImages = 50;
let imageIndex = 1;
function populateGallery() {
  document.querySelectorAll(".images").forEach((container) => {
    for (let j = 0; j < imagesPerProject; j++) {
      if (imageIndex > totalImages) imageIndex = 1;     // wrap/cycle
      const cell = document.createElement("div");
      cell.classList.add("img");
      const img = document.createElement("img");
      img.src = `/img${imageIndex}.jpg`;
      img.alt = `Project Image ${imageIndex}`;
      cell.appendChild(img);
      container.appendChild(cell);
      imageIndex++;
    }
  });
}
populateGallery();

Each of the 8 .images gets 6 .img cells → 48 images total, numbered sequentially img1…img48 (the cycle-back at 50 never triggers with these counts). Adjust the /img${i}.jpg path to wherever your fixtures live.

1) Side progress bar
ScrollTrigger.create({
  trigger: "body",
  start: "top top",
  end: "bottom bottom",
  onUpdate: (self) => gsap.set(".progress-bar", { scaleY: self.progress }),
});

The 8px right bar's scaleY tracks whole-page scroll progress 0→1 (fills downward from transform-origin:top). Per-frame gsap.set, no ease.

2) Bottom-right preview image follows the centered gallery image

For every .img img create a trigger keyed to the viewport's vertical center:

const previewImg = document.querySelector(".preview-img img");
document.querySelectorAll(".img img").forEach((img) => {
  ScrollTrigger.create({
    trigger: img,
    start: "top 50%",
    end: "bottom 50%",
    onEnter:     () => (previewImg.src = img.src),
    onEnterBack: () => (previewImg.src = img.src),
  });
});

Whenever a gallery image crosses the 50%-height line (scrolling either direction) the floating preview swaps its src to that image. No tween — an instant source swap.

3) Name-list indicator hop + active name
const indicator = document.querySelector(".indicator");
const indicatorStep = 18;                       // px per row (= .name height)
const names = gsap.utils.toArray(".name");
gsap.set(".indicator", { top: "0px" });
const projects = gsap.utils.toArray(".project");

projects.forEach((project, index) => {
  ScrollTrigger.create({
    trigger: project,
    start: "top 50%",
    end: "bottom 50%",
    onEnter: () => {
      gsap.to(indicator, { top: Math.max(0, index * indicatorStep) + "px", duration: 0.3, ease: "power2.out" });
      names.forEach((n, i) => n.classList.toggle("active", i === index));
    },
    onLeaveBack: () => {
      const target = index - 1 < 0 ? 0 : (index - 1) * indicatorStep;
      gsap.to(indicator, { top: target + "px", duration: 0.3, ease: "power2.out" });
      names.forEach((n, i) => n.classList.toggle("active", i === (index - 1 < 0 ? 0 : index - 1)));
    },
  });
});

As each project's top crosses viewport-center the triangle slides to row index (18px per row) over 0.3s power2.out and that name row becomes black/active; scrolling back up returns it to the previous row.

4) The star: fixed-then-peel masked number (per-frame, velocity-aware)

For every project, cache its four layers, reset transforms, and build one big onUpdate trigger:

projects.forEach((project, i) => {
  const mask = project.querySelector(".mask");
  const digitWrapper = project.querySelector(".digit-wrapper");
  const firstDigit = project.querySelector(".first");
  const secondDigit = project.querySelector(".second");

  gsap.set([mask, digitWrapper, firstDigit, secondDigit], { y: 0 });
  gsap.set(mask, { position: "absolute", top: 0 });

  ScrollTrigger.create({
    trigger: project,
    start: "top bottom",
    end: "bottom top",
    anticipatePin: 1,
    fastScrollEnd: true,
    preventOverlaps: true,
    onUpdate: (self) => {
      const projectRect = project.getBoundingClientRect();
      const windowCenter = window.innerHeight / 2;
      const nextProject = projects[i + 1];
      const velocityAdjustment = Math.min(scrollVelocity * 0.1, 100);
      const pushPoint = window.innerHeight * (0.85 + velocityAdjustment / window.innerHeight);

      if (projectRect.top <= windowCenter) {
        // (a) once this project reaches center, pin its number to screen-center
        if (!mask.isFixed) {
          mask.isFixed = true;
          gsap.set(mask, { position: "fixed", top: "50vh" });
        }
        // (b) when the NEXT project climbs to the push point, peel this number up
        if (nextProject) {
          const nextRect = nextProject.getBoundingClientRect();
          if (nextRect.top <= pushPoint && activeIndex !== i + 1) {
            gsap.killTweensOf([mask, digitWrapper, firstDigit, secondDigit]);
            activeIndex = i + 1;
            gsap.to(mask,        { y: -80, duration: 0.3,  ease: "power2.out", overwrite: true });
            gsap.to(digitWrapper,{ y: -80, duration: 0.5,  delay: 0.5, ease: "power2.out", overwrite: true });
            gsap.to(firstDigit,  { y: -80, duration: 0.75, ease: "power2.out", overwrite: true });
            gsap.to(secondDigit, { y: -80, duration: 0.75, delay: 0.1, ease: "power2.out", overwrite: true });
          }
        }
      } else {
        // above center → keep number in its normal absolute spot
        mask.isFixed = false;
        gsap.set(mask, { position: "absolute", top: 0 });
      }

      // (c) scrolling UP and this project's top is back below center → restore the PREVIOUS number
      if (self.direction === -1 && projectRect.top > windowCenter) {
        mask.isFixed = false;
        gsap.set(mask, { position: "absolute", top: 0 });
        if (i > 0 && activeIndex === i) {
          const prev = projects[i - 1];
          const pMask = prev.querySelector(".mask");
          const pWrap = prev.querySelector(".digit-wrapper");
          const pFirst = prev.querySelector(".first");
          const pSecond = prev.querySelector(".second");
          gsap.killTweensOf([pMask, pWrap, pFirst, pSecond]);
          activeIndex = i - 1;
          gsap.to([pMask, pWrap], { y: 0, duration: 0.3,  ease: "power2.out", overwrite: true });
          gsap.to(pFirst,         { y: 0, duration: 0.75, ease: "power2.out", overwrite: true });
          gsap.to(pSecond,        { y: 0, duration: 0.75, delay: 0.1, ease: "power2.out", overwrite: true });
        }
      }
    },
    onEnter: () => { if (i === 0) activeIndex = 0; },
  });
});

Precise behavior to reproduce:

  • Pin at center: while projectRect.top <= innerHeight/2, the number's .mask flips to position:fixed; top:50vh (its left:2em is untouched, so it hangs at the middle-left of the screen). A per-element boolean flag mask.isFixed guards the one-time flip. Above center it flips back to position:absolute; top:0.
  • Push point is velocity-aware: pushPoint = innerHeight * (0.85 + velAdj/innerHeight) where velAdj = min(scrollVelocity*0.1, 100). At rest the next project must climb to 85% of viewport height to trigger the peel; faster scrolling nudges that threshold lower (later), capped by the +100px clamp. scrollVelocity comes from the scroll listener below.
  • The peel (layered stagger, all y:0 → y:-80, all power2.out, all overwrite:true):
  • .maskduration 0.3, no delay (the clipping window itself lifts first/fastest).
  • .first (first digit) — duration 0.75, no delay.
  • .second (second digit) — duration 0.75, delay 0.1 (trails the first digit by 0.1s).
  • .digit-wrapperduration 0.5, delay 0.5 (starts latest).

These four independent tweens on nested layers compound (each layer's -80 adds to its ancestor's) and, because they have different speeds/delays, the number appears to peel apart and slide up out of the 70px mask in staggered layers rather than as one rigid block. An activeIndex latch (activeIndex !== i + 1) plus killTweensOf ensures the peel fires exactly once per boundary.

  • Reverse on scroll-up: when scrolling up (self.direction === -1) and the current project falls back below center, the previous project's number is animated back to y:0 (mask+wrapper 0.3s, first 0.75s, second 0.75s/delay 0.1s) and activeIndex steps back to i-1, so numbers reassemble as you scroll upward.
5) Scroll-velocity tracker (feeds the push point)
window.addEventListener("scroll", () => {
  const st = window.pageYOffset;
  scrollVelocity = Math.abs(st - lastScrollTop);
  lastScrollTop = st;
}, { passive: true });

scrollVelocity = absolute px delta per scroll event; larger deltas raise velocityAdjustment (clamped 0–100) which shifts the peel's pushPoint.

Assets / images

A single editorial photography set of 50 images (img1.jpg … img50.jpg); the layout consumes the first 48 (6 per project × 8 projects), plus img1.jpg seeds the preview panel. All are shown with object-fit:cover, so exact source aspect ratios don't matter — provide a mix (the set includes tall 2:3 portraits, near-1:1 frames, and wide ~16:9 / 3:2 landscapes). Mood: quiet, natural-light, film-grain editorial — a weathered older man's face in soft daylight, a freckled red-haired woman close-up, a hands-on-piano-keys low-key interior, a studio side-profile against a saturated red backdrop, and similar character portraits, tight close-ups, and atmospheric lifestyle/environmental shots. Neutral palette overall (skin tones, warm daylight, occasional bold single-color studio backdrop). No logos, no text, no brand marks. If you have fewer than 48 unique photos, repeat them to fill the grid.

Behavior notes

  • Trigger: scroll only (Lenis-smoothed). Nothing autoplays; every effect is scroll-position driven via ScrollTrigger (progress bar and preview are per-frame/threshold; the number peel is discrete tweens fired at boundaries).
  • The two .whitespace spacers (50vh top, 45vh bottom) plus the 20em inter-project gaps give the first and last numbers room to reach and leave screen-center.
  • getBoundingClientRect(), window.innerHeight, and window.pageYOffset are read live each frame, so the layout stays correct on resize/viewport changes without recomputing offsets.
  • Desktop-oriented full-viewport composition; there is no reduced-motion guard and no separate mobile breakpoint in the original — the same triggers run at all sizes.