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-rowelements, each containing exactly 9.projectcards (90 cards total). The row count and card count both matter — the section-height precompute multiplies byrows.length, and 9 flex cards per row set the aspect/height math. - Each
.projectholds a.project-img(the image, cropped) above a.project-infobar 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:hiddenare 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.5remverticalgapand0.5rem 0paddingare 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; }— thewidth:125%is the animation's start value (the CSS default before JS takes over); JS overwritesstyle.widthevery frame.1remhorizontal gap between cards..project { flex:1; aspect-ratio:7/5; display:flex; flex-direction:column; overflow:hidden; }— nine equalflex:1cards; the fixed7/5aspect 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:0lets 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,
progressis0while the row's top is still ≥ one full viewport-height below the current scroll (row about to appear from the bottom), and reaches1once the page has scrolled such thatscrollY === 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 inlinewidthon the row. Strictly linear — the only smoothing is Lenis's inertial scroll feedingwindow.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. The7/5card 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
svhon 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.scrollYlive, so it self-corrects as the locked height changes. - No reduced-motion handling in the original.