Web Animations Medium
Lenis Smooth Scroll
Wire Lenis inertia scrolling to GSAP ScrollTrigger with scrollerProxy so scrub and pin animations stay in sync.
Open in Lab
MCP
html css gsap lenis javascript
Targets: JS HTML
Code
[hidden] {
display: none !important;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: auto;
}
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: #0a0a0f;
color: #e8e6f0;
line-height: 1.5;
}
.hero {
min-height: 100vh;
display: grid;
place-content: center;
padding: 2rem;
text-align: center;
background: radial-gradient(ellipse 80% 60% at 50% 0%, #1a1530 0%, #0a0a0f 70%);
}
.eyebrow {
margin: 0 0 0.75rem;
font-size: 0.75rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #9b8cff;
}
.hero h1 {
margin: 0 0 1rem;
font-size: clamp(2rem, 5vw, 3.25rem);
font-weight: 600;
letter-spacing: -0.03em;
}
.lede {
margin: 0 auto;
max-width: 32rem;
color: #a8a4b8;
}
code {
font-size: 0.9em;
color: #c4b8ff;
}
.panel {
min-height: 90vh;
display: grid;
place-items: center;
padding: 4rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.panel--accent {
background: linear-gradient(180deg, #12101a 0%, #0a0a0f 100%);
}
.panel-inner {
max-width: 28rem;
opacity: 0;
transform: translateY(48px);
}
.num {
display: block;
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: #6b6578;
}
.panel h2 {
margin: 0 0 0.75rem;
font-size: 1.75rem;
letter-spacing: -0.02em;
}
.panel p {
margin: 0;
color: #a8a4b8;
}
.outro {
padding: 4rem 1.5rem 6rem;
text-align: center;
color: #6b6578;
font-size: 0.875rem;
}const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
gsap.registerPlugin(ScrollTrigger);
function initPanels() {
document.querySelectorAll("[data-panel] .panel-inner").forEach((el) => {
if (reduced) {
gsap.set(el, { opacity: 1, y: 0 });
return;
}
gsap.fromTo(
el,
{ opacity: 0, y: 48 },
{
opacity: 1,
y: 0,
duration: 0.9,
ease: "power3.out",
scrollTrigger: {
trigger: el.closest("[data-panel]"),
start: "top 75%",
end: "top 40%",
scrub: 0.6,
},
}
);
});
}
if (reduced || typeof Lenis === "undefined") {
initPanels();
ScrollTrigger.refresh();
} else {
const lenis = new Lenis({ duration: 1.1, smoothWheel: true, autoRaf: false });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
gsap.ticker.lagSmoothing(0);
ScrollTrigger.scrollerProxy(document.documentElement, {
scrollTop(value) {
if (arguments.length) {
lenis.scrollTo(value, { immediate: true });
}
return lenis.scroll;
},
getBoundingClientRect() {
return {
top: 0,
left: 0,
width: window.innerWidth,
height: window.innerHeight,
};
},
});
initPanels();
ScrollTrigger.addEventListener("refresh", () => lenis.resize());
ScrollTrigger.refresh();
}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lenis Smooth Scroll</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="hero">
<p class="eyebrow">Lenis + ScrollTrigger</p>
<h1>Smooth scroll that still scrubs</h1>
<p class="lede">Scroll down — motion stays tied to Lenis via <code>scrollerProxy</code>.</p>
</header>
<section class="panel" data-panel>
<div class="panel-inner">
<span class="num">01</span>
<h2>Momentum without drift</h2>
<p>Wheel input eases out; ScrollTrigger reads the same scroll position Lenis animates.</p>
</div>
</section>
<section class="panel panel--accent" data-panel>
<div class="panel-inner">
<span class="num">02</span>
<h2>Proxy the scroller</h2>
<p>Map <code>scrollTop</code> to <code>lenis.scroll</code> so pins and scrub timelines stay aligned.</p>
</div>
</section>
<section class="panel" data-panel>
<div class="panel-inner">
<span class="num">03</span>
<h2>Reduced motion</h2>
<p>When users prefer less motion, Lenis is skipped and content is fully visible.</p>
</div>
</section>
<footer class="outro">
<p>StealThis · Lenis smooth scroll demo</p>
</footer>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lenis.min.js"></script>
<script src="script.js"></script>
</body>
</html>Lenis Smooth Scroll
Lenis handles wheel smoothing while ScrollTrigger drives section reveals. scrollerProxy maps Lenis scroll position to what GSAP expects.
When to use it
- Marketing sites that need buttery scroll without breaking pin/scrub timelines
- Any page mixing Lenis with GSAP scroll animations
Notes
- Respects
prefers-reduced-motion(native scroll, no Lenis). - Load GSAP, ScrollTrigger, and Lenis from CDN before your init script.