All components

Lenis Smooth Scroll + ScrollTrigger

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

Open live demo ↗ Raw prompt (.md)

What it does

A services list where each row's thumbnail expands from 30% to 100% width while the row itself grows in height as it scrolls into view. Lenis drives smooth scrolling synced to gsap.ticker, and two per-row scrubbed ScrollTrigger instances (created lazily via IntersectionObserver) read self.progress to tween the image width and row height with ease 'none'.

How it's built

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

scroll scrolltrigger lenis smooth-scroll image-reveal scrub intersection-observer services corporate

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

Lenis Smooth Scroll + ScrollTrigger — Expanding Service-Row Thumbnails

Goal

Build a long, smooth-scrolling page with a full-screen photo hero, a black "All Services" list section, and a full-screen photo footer. The star effect is in the services list: it is a stack of thin service rows, and as each row scrolls up into view its small thumbnail expands from 30% to 100% width while the row itself grows in height from 150px to 450px, driven by two per-row scrubbed ScrollTriggers. Scrolling is smoothed by Lenis synced to GSAP's ticker, and the per-row triggers are created lazily the first time each row enters the viewport (via an IntersectionObserver).

Tech

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

  • gsap (3.x) plus the plugin ScrollTrigger.
  • lenis — smooth scroll, wired into GSAP's ticker.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger);

No other GSAP plugins, no SplitText, no CustomEase, no Three.js, no framework. Run all setup inside document.addEventListener("DOMContentLoaded", …).

Layout / HTML

One .container wrapping three stacked blocks in order: a .hero section, a .services section, and a .footer section. Class names are load-bearing — the JS queries .service and .img.

<div class="container">
  <section class="hero"></section>

  <section class="services">
    <div class="services-header">
      <div class="col"></div>
      <div class="col"><h1>All Services</h1></div>
    </div>

    <div class="service">
      <div class="service-info">
        <h1>Custom Web Development</h1>
        <p>We provide bespoke web development solutions tailored to your business needs. Our team ensures top-notch performance and scalability.</p>
      </div>
      <div class="service-img">
        <div class="img"><img src="…thumb-1" alt="" /></div>
      </div>
    </div>

    <!-- 4 more identical .service rows, see copy below -->
  </section>

  <section class="footer"></section>
</div>

<script type="module" src="./script.js"></script>
  • Five .service rows. Each row is: a .service-info column (an <h1> title on top, a <p> description below, pushed apart top/bottom) + a .service-img column that contains a single .img wrapper with one <img>.
  • .hero and .footer are empty sections — their imagery comes entirely from CSS background.
  • The .services-header has two .cols: the first is an empty spacer, the second holds the <h1> "All Services".
  • Neutral corporate copy (no brands). The five rows, in order — title / description:
  • Custom Web Development — "We provide bespoke web development solutions tailored to your business needs. Our team ensures top-notch performance and scalability."
  • Mobile App Development — "Crafting intuitive and engaging mobile applications for both Android and iOS platforms. Enhance your user experience with our expert team."
  • Digital Marketing — "Comprehensive digital marketing services to boost your online presence. From SEO to social media campaigns, we cover it all."
  • Cloud Solutions — "Reliable and secure cloud solutions to streamline your business operations. Leverage the power of the cloud with our expertise."
  • IT Consultancy — "Expert IT consultancy services to guide your business through digital transformation. Optimize your IT infrastructure with our insights."

Styling

Font: "PP Neue Montreal" (a clean neutral grotesque) on html, body; fall back to a system sans-serif if unavailable.

Reset & base:

  • * { margin:0; padding:0; box-sizing:border-box; }
  • .container { width:100%; height:100%; }
  • h1 { color:#fff; font-size:36px; font-weight:500; }
  • p { color:#fff; font-size:15px; font-weight:400; line-height:150%; }
  • img { width:100%; height:100%; object-fit:cover; }

Sections:

  • .hero { width:100vw; height:100vh; background:url(…hero) no-repeat 50% 50%; background-size:cover; padding:2em; }
  • .footer { width:100%; height:100vh; background:url(…footer) no-repeat 50% 50%; background-size:cover; }
  • .services { background:#000; padding:8em 2em; display:flex; flex-direction:column; } — the list sits on a pure black background between the two photo panels.

Services header:

  • .services-header { width:100%; display:flex; gap:4em; }
  • .services-header .col:nth-child(1) { flex:2; } (empty spacer, matches the 2/5 column split below)
  • .services-header .col:nth-child(2) { flex:5; padding:1em; } ("All Services" heading, left-aligned over the image column)

Service rows (the structure the effect hangs on):

  • .service { display:flex; gap:2em; height:150px; border-top:1px solid rgba(255,255,255,0.2); } — every row starts 150px tall with a faint 1px top divider (so rows read as a ruled list).
  • .service-info { flex:2; width:100%; height:100%; display:flex; flex-direction:column; justify-content:space-between; padding:1em; } (title top, description bottom)
  • .service-img { flex:5; width:100%; height:100%; padding:1em; } — the image column is 2.5× wider than the text column (flex 5 vs 2).
  • .img { width:30%; height:100%; border-radius:10px; overflow:hidden; }the thumbnail starts at 30% of the image column's width and full row height, with rounded corners and clipped overflow. This 30% start width and the 150px row height are the two values the GSAP effect animates.
Lenis recommended CSS (include verbatim — one line is critical)
html.lenis, html.lenis body { height: 500vh; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
.lenis.lenis-stopped { overflow: hidden; }
.lenis.lenis-smooth iframe { pointer-events: none; }

html.lenis, html.lenis body { height: 500vh; } is essential. Lenis stamps the classes lenis lenis-smooth onto <html> when it initializes, which activates this rule and forces the document to 500 viewport-heights tall. That long runway is what gives every row a generous scroll distance for its width/height scrub — without it the page would be far too short for the effect to read.

GSAP effect (the important part — be exhaustive)

1) Smooth-scroll wiring (Lenis ↔ GSAP ticker)
const lenis = new Lenis();               // default options
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);

Lenis is driven by GSAP's ticker (so lenis.raf is called each frame with time*1000), every Lenis scroll event calls ScrollTrigger.update, and lag smoothing is disabled so the scrub stays glued to the scroll position. (The original also has a debug lenis.on("scroll", (e)=>console.log(e)) — omit it to keep the console clean.)

2) Lazy per-row trigger creation via IntersectionObserver

Collect the rows and observe them; only when a row first becomes ~10% visible do we build its two ScrollTriggers, then stop observing it:

const services = gsap.utils.toArray(".service"); // the 5 rows

const observerOptions = { root: null, rootMargin: "0px", threshold: 0.1 };

const observerCallback = (entries, observer) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      const service = entry.target;
      const imgContainer = service.querySelector(".img");

      /* --- Trigger A + Trigger B created here (see below) --- */

      observer.unobserve(service); // create each row's triggers exactly once
    }
  });
};

const observer = new IntersectionObserver(observerCallback, observerOptions);
services.forEach((service) => observer.observe(service));

So the triggers are built just-in-time, once per row, the moment the row scrolls into view. Reproduce this lazy pattern (not an up-front loop) for identical behavior.

3) Trigger A — thumbnail width 30% → 100% (scrubbed)
ScrollTrigger.create({
  trigger: service,
  start: "bottom bottom",   // row's bottom edge reaches the viewport bottom
  end:   "top top",         // row's top edge reaches the viewport top
  scrub: true,
  onUpdate: (self) => {
    const progress = self.progress;          // 0 → 1
    const newWidth = 30 + 70 * progress;     // 30% → 100%
    gsap.to(imgContainer, {
      width: newWidth + "%",
      duration: 0.1,
      ease: "none",
    });
  },
});

As the row travels from "its bottom at the viewport bottom" up to "its top at the viewport top", the .img wrapper grows linearly from 30% to 100% width, so the thumbnail expands rightward to fill the whole image column.

4) Trigger B — row height 150px → 450px (scrubbed)
ScrollTrigger.create({
  trigger: service,
  start: "top bottom",      // row's top edge enters at the viewport bottom
  end:   "top top",         // row's top edge reaches the viewport top
  scrub: true,
  onUpdate: (self) => {
    const progress = self.progress;          // 0 → 1
    const newHeight = 150 + 300 * progress;  // 150px → 450px
    gsap.to(service, {
      height: newHeight + "px",
      duration: 0.1,
      ease: "none",
    });
  },
});

Over the row's top edge crossing the full viewport (bottom → top), the .service row grows linearly from 150px to 450px tall. Because .img is height:100%, the thumbnail gets taller as the row grows, and simultaneously wider via Trigger A — the two combine so the thumbnail blooms open as the row rises.

Exact mechanics to reproduce
  • Two independent ScrollTriggers per row, both scrub: true (bound directly to scroll, no numeric catch-up). They target the same row but have different start points: Trigger B (height) starts earlier at top bottom; Trigger A (width) starts later at bottom bottom; both end at top top. So the height begins growing as soon as the row's top peeks in, and the width expansion kicks in a little later once the row's bottom clears the viewport bottom.
  • Not gsap.set — a tiny gsap.to. Each onUpdate fires a gsap.to(..., { duration: 0.1, ease: "none" }), i.e. a 0.1s linear catch-up tween toward the freshly computed value every frame. This adds a subtle smoothing/lag on top of the scrub + Lenis (do not replace it with an instantaneous gsap.set).
  • Linear everywhere. ease: "none"; the value maps are plain linear formulas (30 + 70*p, 150 + 300*p). No easing curve, no stagger, no timeline, no labels, no delay.
  • No SplitText, no CustomEase, no manual lerp/rAF loop, no Three.js. Lenis owns the rAF loop through gsap.ticker; ScrollTrigger owns the scroll mapping.

Net read: a black ruled list where each row, as you scroll it up the screen, simultaneously stretches taller (150→450px) and its rounded thumbnail widens (30%→100%) to fill the row — a calm, corporate, scroll-scrubbed image reveal, one row after another, all under Lenis smooth scroll.

Assets / images

Seven photographs total, all object-fit:cover / background-size:cover (so exact source aspect is flexible — cover-crop handles it). Use a cohesive set of moody, richly-colored editorial photographs (mixed subjects, saturated color on dark or neutral backgrounds — abstract art-direction, NOT literal depictions of the service titles).

  • Hero background (1 image, landscape ~3:2): full-bleed atmospheric macro — e.g. pale golden botanical sprouts against a deep indigo/violet bokeh. Fills the top .hero viewport.
  • Five row thumbnails (.service .img, in row order): landscape-to-square editorial photos, cover-cropped into the expanding rounded frame (frame aspect changes live from a narrow 30%-wide sliver to a full-width band as it animates):
  • sepia/beige portrait — a figure covering their face with both hands wearing many chunky silver rings (~3:2).
  • warm copper/bronze macro of curled dried autumn leaves on a dark ground (~16:9).
  • tight close-up of a black panther's face with pale eyes on a soft grey ground (1:1 square).
  • two cream/ivory moths in flight against a deep maroon velvet ground (~3:2).
  • three purple bearded irises against a deep red tiled ground (~3:2).
  • Footer background (1 image, portrait ~3:4): full-bleed still-life — e.g. an ornate spoon holding amber rock-sugar crystals against a dark teal-blue ground. Fills the closing .footer viewport.

Any cohesive moody-editorial photo set works; provide the images in the order above. If fewer thumbnails are available than 5 rows, repeat in order.

Behavior notes

  • Trigger: scroll only, fully scrubbed. Parking the scroll freezes each row mid-expansion; scrolling back up reverses it. Nothing autoplays.
  • Lazy init: a row's two triggers don't exist until it first intersects the viewport (threshold 0.1), then it's unobserved so they're created exactly once. Keep this — creating all triggers up front changes the exact progress mapping for the first rows.
  • The 500vh document height (applied via the html.lenis class Lenis adds) is what supplies the long scroll runway; keep the Lenis CSS block intact.
  • No reduced-motion guard and no invalidateOnRefresh in the original — desktop-first; the end/start are resolved by ScrollTrigger from element position. The layout is a simple 2/5 flex split that holds up down to tablet widths.