All components

Ingamana Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

A 10-row project grid whose rows expand from 125% to 500% width (250% to 750% on mobile) based on each row's individual scroll progress through the viewport, producing a continuous zoom-in on the images as you scroll. Driven by a gsap.ticker callback reading window.scrollY (no ScrollTrigger) with Lenis smooth scroll; the section height is pre-computed for the fully expanded state.

How it's built

Categoryscroll
Techgsap, lenis
Complexitysection
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll grid zoom expand gallery lenis ticker images

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

Ingamana Scroll Animation

Goal

Build a long scroll page: a full-screen intro, then a 10-row image grid, then a full-screen outro. The signature effect: each grid row continuously widens as you scroll it through the viewport — from 125% to 500% of the viewport width (250% → 750% on mobile) — and because every row is horizontally centered inside an overflow:hidden container, the extra width bleeds off both edges symmetrically, so the whole row (and its images) reads as a smooth zoom-in / push-in the deeper it travels up the screen. Every row runs its own zoom independently, keyed to that row's own scroll progress, producing a staggered cascade of expanding rows. Smooth scroll via Lenis. Crucially, this is NOT a ScrollTrigger effect — it is hand-computed every frame from window.scrollY inside a gsap.ticker callback.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) and lenis (npm) for smooth scroll. No GSAP plugins at all — no ScrollTrigger, no SplitText, no CustomEase, no Three.js. No framework. Plain Vite-style module imports:

import gsap from "gsap";
import Lenis from "lenis";

Layout / HTML

Three top-level blocks in <body>:

section.intro                 (full-screen, centered)  > p "Intro Section"
section.projects              (the grid; JS sets its explicit pixel height)
  div.projects-row            × 10   (each row is a horizontal flex strip)
    div.project               × 9    (nine cards per row)
      div.project-img > img
      div.project-info > p (label) + p (year)
section.outro                 (full-screen, centered)  > p "Outro Section"

<script type="module" src="./script.js">   (before </body>)

Details that the CSS/JS depend on:

  • Exactly 10 .projects-row elements, each containing exactly 9 .project cards (90 cards total). The row count and card count both matter — the section-height precompute multiplies by rows.length, and 9 flex cards per row set the aspect/height math.
  • Each .project holds a .project-img (the image, cropped) above a .project-info bar with two <p>: a short label on the left and a 4-digit year on the right.
  • Fill the 90 cards by cycling through 16 distinct images (img1…img16, then repeat). Use neutral one/two-word placeholder labels + years, e.g. Fieldnotes 2020, Redline 2021, Gallery Walk 2019, Side Profile 2022, Open Mic 2023, Backboard 2024, Afterglow 2021, Hill House 2020, Low Tide 2018, Timepiece 2019, Close Focus 2022, Airframe 2023, Hardcase 2024, Deep Red 2021, Fast Track 2022, Night Shift 2025. Invent nothing brand-related. Intro/outro paragraphs are literally "Intro Section" / "Outro Section".

Styling

Import font:

@import url("https://fonts.cdnfonts.com/css/pp-neue-montreal");

Global / reset:

  • * { margin:0; padding:0; box-sizing:border-box; }
  • body { font-family:"PP Neue Montreal", sans-serif; }
  • img { width:100%; height:100%; object-fit:cover; } — images always fill their box, cropped.

Typography:

  • p { text-transform:uppercase; font-size:0.9rem; font-weight:500; letter-spacing:-0.02rem; line-height:1; }
  • .project-info p { font-size:0.75rem; } (smaller than the intro/outro labels).

Intro & outro (full-screen bookends):

  • .intro, .outro { position:relative; width:100%; height:100svh; display:flex; justify-content:center; align-items:center; overflow:hidden; }

The grid — this is the part the effect hangs on:

  • .projects { position:relative; width:100%; padding:0.5rem 0; display:flex; flex-direction:column; align-items:center; gap:0.5rem; overflow:hidden; }
  • align-items:center + overflow:hidden are load-bearing: rows are wider than the viewport and centered, so growth spills off both sides equally and is clipped — that symmetric clip is what makes it read as a centered zoom rather than a rightward slide.
  • The 0.5rem vertical gap and 0.5rem 0 padding are read back by the JS to compute the section's fixed height (see below), so keep them exactly.
  • .projects-row { width:125%; display:flex; gap:1rem; } — the width:125% is the animation's start value (the CSS default before JS takes over); JS overwrites style.width every frame. 1rem horizontal gap between cards.
  • .project { flex:1; aspect-ratio:7/5; display:flex; flex-direction:column; overflow:hidden; } — nine equal flex:1 cards; the fixed 7/5 aspect ratio is why a wider row is also a taller row (the images grow in both dimensions as the row expands).
  • .project-img { flex:1; min-height:0; overflow:hidden; } (image area takes all remaining card height above the info bar; min-height:0 lets it shrink correctly inside the flex column).
  • .project-info { display:flex; justify-content:space-between; padding:0.25rem 0; } (label left, year right).

GSAP effect (the important part — be exhaustive)

There is no gsap tween and no timeline anywhere. gsap is used only for (a) driving Lenis and (b) its high-frequency ticker as a rAF loop; all width changes are computed by hand and written to row.style.width. Do not substitute ScrollTrigger — reproduce the manual scrollY math exactly.

Everything runs inside document.addEventListener("DOMContentLoaded", () => { ... }).

1) Smooth-scroll wiring (Lenis driven by the GSAP ticker)
const lenis = new Lenis({ autoRaf: false });          // Lenis does NOT run its own rAF
gsap.ticker.add((time) => { lenis.raf(time * 1000); }); // GSAP's ticker drives Lenis (time is seconds → ms)
gsap.ticker.lagSmoothing(0);                          // disable lag smoothing so motion stays glued to scroll

No gsap.registerPlugin(...) call (there are no plugins).

2) Cache elements, breakpoint, and the start/end widths
const section = document.querySelector(".projects");
if (!section) return;
const rows = Array.from(section.querySelectorAll(".projects-row")); // the 10 rows

const isMobile = window.innerWidth < 1000;      // breakpoint at 1000px
let rowStartWidth = isMobile ? 250 : 125;       // START width % (mobile 250, desktop 125)
let rowEndWidth   = isMobile ? 750 : 500;       // END   width % (mobile 750, desktop 500)

So desktop rows animate 125% → 500% width; mobile rows animate 250% → 750%.

3) Pre-compute and lock the section's full-expanded height (prevents scroll jump)

Because a row is far taller at 500% width than at 125%, letting the grid reflow as rows grow would make the page height (and every row's scroll position) shift mid-scroll. To avoid that, reserve the fully-expanded height up front and pin it as an explicit pixel height on .projects:

const firstRow = rows[0];
firstRow.style.width = `${rowEndWidth}%`;          // temporarily expand row 1 to its END width…
const expandedRowHeight = firstRow.offsetHeight;   // …measure how tall a fully-expanded row is…
firstRow.style.width = "";                         // …then revert (CSS 125% start returns)

const sectionGap     = parseFloat(getComputedStyle(section).gap) || 0;        // 0.5rem → 8px
const sectionPadding = parseFloat(getComputedStyle(section).paddingTop) || 0; // 0.5rem → 8px

const expandedSectionHeight =
  expandedRowHeight * rows.length +      // 10 fully-expanded rows
  sectionGap * (rows.length - 1) +       // 9 inter-row gaps
  sectionPadding * 2;                    // top + bottom padding

section.style.height = `${expandedSectionHeight}px`;   // lock the height

This makes the total scrollable distance constant, so each row's progress window is stable while its width animates.

4) The per-frame zoom (runs every ticker tick)

Add a second callback to the same gsap.ticker; it recomputes every row's width from the live window.scrollY on every frame:

function onScrollUpdate() {
  const scrollY = window.scrollY;
  const viewportHeight = window.innerHeight;

  rows.forEach((row) => {
    const rect = row.getBoundingClientRect();
    const rowTop = rect.top + scrollY;         // row's absolute top in the document
    const rowBottom = rowTop + rect.height;    // row's absolute bottom

    const scrollStart = rowTop - viewportHeight; // progress 0 when the row's top is one viewport below the scroll
    const scrollEnd   = rowBottom;               // progress 1 when scrollY reaches the row's bottom

    let progress = (scrollY - scrollStart) / (scrollEnd - scrollStart);
    progress = Math.max(0, Math.min(1, progress)); // clamp 0…1

    const width = rowStartWidth + (rowEndWidth - rowStartWidth) * progress; // linear interpolate
    row.style.width = `${width}%`;
  });
}
gsap.ticker.add(onScrollUpdate);

Exact behavior to reproduce:

  • Per-row progress window: for each row, progress is 0 while the row's top is still ≥ one full viewport-height below the current scroll (row about to appear from the bottom), and reaches 1 once the page has scrolled such that scrollY === rowBottom (row's bottom crossing the very top). Between those two points progress ramps linearly.
  • Width mapping: width% = rowStartWidth + (rowEndWidth - rowStartWidth) * progress, i.e. 125% → 500% desktop (250% → 750% mobile), applied as an inline width on the row. Strictly linear — the only smoothing is Lenis's inertial scroll feeding window.scrollY.
  • Independent per row: each row has its own start/end scroll positions, so at any moment different rows sit at different widths — a continuous, staggered cascade of expanding rows, not one synchronized move.
  • Because the row is centered (align-items:center) inside a clipped container, the growth is symmetric about the horizontal center → centered zoom-in. The 7/5 card aspect ratio means the images enlarge vertically too as the row widens.
5) Resize handler (recompute breakpoint widths + locked height)
window.addEventListener("resize", () => {
  const isMobileNow = window.innerWidth < 1000;
  rowStartWidth = isMobileNow ? 250 : 125;
  rowEndWidth   = isMobileNow ? 750 : 500;

  firstRow.style.width = `${rowEndWidth}%`;
  const newRowHeight = firstRow.offsetHeight;
  firstRow.style.width = "";

  const newSectionHeight =
    newRowHeight * rows.length +
    sectionGap * (rows.length - 1) +
    sectionPadding * 2;
  section.style.height = `${newSectionHeight}px`;
});

Re-derives the start/end widths for the current breakpoint and re-locks the section height (same formula). It does not otherwise re-run the zoom — the ticker keeps computing widths from the new values on the next frame.

No ease keyword, duration, delay, or stagger anywhere — none of GSAP's tweening API is used. The "stagger" look emerges naturally from each row's distinct scroll window; the "ease" is just Lenis smoothing the scroll input.

Assets / images

16 distinct images, landscape framing roughly 7:5 (they render inside .project-img at object-fit:cover, so exact source ratio is forgiving — they get cropped to fill). The set is an eclectic, editorial photo mix rather than one single subject or palette — each frame is a different subject, color story, and lighting, which is what makes the widening rows read as a curated gallery. Representative frames from the set:

  • Botanical / nature macros with soft, shallow-depth-of-field backgrounds: pale seedling sprouts lit against a deep blue-violet blurred backdrop; a cluster of purple-and-white bearded irises with yellow beards shot against a deep crimson-red tiled wall; curled, backlit dried autumn leaves in warm coppery browns against a near-black background.
  • A product still life — a tan/beige suede ankle boot with a stacked wooden heel, mid-air on a clean off-white seamless studio background with a soft drop shadow.
  • An expressive portrait — a motion-blurred, open-mouthed screaming face streaked across a flat saturated red field; grainy, raw, high-energy.
  • An interior / architecture frame — a minimalist boucle lounge chair and small potted plant in a warm-lit, curved cream plaster room.

Common thread: clean, intentional photography with strong single-subject focus and generous negative space; dominant colors span deep violet-blue, crimson red, warm amber/brown, and neutral cream/beige. Cycle the 16 across the 90 cards (img1…img16, repeat). No brand marks, logos, or text baked into the images.

Behavior notes

  • Scroll-scrubbed and fully reversible — nothing autoplays; every row width is a pure function of the current scroll position, so scrolling back up un-zooms the rows. The ticker recomputes all rows each frame regardless of scroll direction.
  • Breakpoint at 1000px only swaps the start/end width pair (125/500 → 250/750); the mechanism is identical on mobile.
  • Heights use svh on the intro/outro so mobile browser chrome doesn't clip the full-screen bookends.
  • The section's pixel height is computed once on load and again on resize; the per-frame math reads getBoundingClientRect() + window.scrollY live, so it self-corrects as the locked height changes.
  • No reduced-motion handling in the original.