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 pluginScrollTrigger.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
.servicerows. Each row is: a.service-infocolumn (an<h1>title on top, a<p>description below, pushed apart top/bottom) + a.service-imgcolumn that contains a single.imgwrapper with one<img>. .heroand.footerare empty sections — their imagery comes entirely from CSSbackground.- The
.services-headerhas 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 attop bottom; Trigger A (width) starts later atbottom bottom; both end attop 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 tinygsap.to. EachonUpdatefires agsap.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 instantaneousgsap.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
.heroviewport. - 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
.footerviewport.
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
500vhdocument height (applied via thehtml.lenisclass Lenis adds) is what supplies the long scroll runway; keep the Lenis CSS block intact. - No reduced-motion guard and no
invalidateOnRefreshin the original — desktop-first; theend/startare resolved by ScrollTrigger from element position. The layout is a simple 2/5 flex split that holds up down to tablet widths.