Web Animations Medium
Lenis + GSAP Scroll Story
Smooth-scrolling narrative sections with timeline triggers.
Open in Lab
MCP
lenis gsap
Targets: JS HTML
Code
:root {
--bg: #050913;
--panel: #121a2b;
--line: #273753;
--text: #eef4ff;
--muted: #c3cfe3;
--accent: #83e8ff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--text);
font-family: "Avenir Next", "Segoe UI", sans-serif;
background: radial-gradient(circle at 15% 12%, rgba(70, 125, 236, 0.28), transparent 34%),
radial-gradient(circle at 82% 78%, rgba(184, 83, 255, 0.24), transparent 36%), var(--bg);
}
.topbar {
position: fixed;
inset: 0 0 auto 0;
z-index: var(--z-ui, 40);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.7rem 1rem;
background: rgba(5, 9, 19, 0.66);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.13);
}
.topbar a {
color: var(--accent);
text-decoration: none;
font-weight: 700;
}
.controls {
display: flex;
gap: 0.6rem;
align-items: center;
}
.hint {
color: var(--muted);
font-size: 0.84rem;
}
.toggle {
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
color: var(--text);
padding: 0.42rem 0.78rem;
cursor: pointer;
}
.progress-wrap {
position: fixed;
top: 53px;
left: 0;
right: 0;
height: 2px;
z-index: var(--z-ui, 40);
background: rgba(255, 255, 255, 0.08);
}
.progress-bar {
display: block;
width: 100%;
height: 100%;
transform-origin: left center;
transform: scaleX(0);
background: linear-gradient(90deg, #72dcff, #ce7eff);
}
main {
padding-top: 3.4rem;
}
.panel {
width: min(1080px, 92%);
min-height: 92vh;
margin: 0 auto;
border-bottom: 1px solid var(--line);
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 1rem;
align-items: center;
position: relative;
}
.content {
display: grid;
gap: 0.8rem;
}
.label {
margin: 0;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.8rem;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: clamp(2.2rem, 7.5vw, 4.8rem);
line-height: 0.95;
}
h2 {
font-size: clamp(1.8rem, 5.3vw, 3.4rem);
}
p {
color: var(--muted);
max-width: 62ch;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.chips span {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
padding: 0.25rem 0.62rem;
background: rgba(255, 255, 255, 0.05);
font-size: 0.83rem;
color: #e4ecfa;
}
.visual {
height: min(540px, 60vh);
position: relative;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(6px);
opacity: 0.58;
}
.orb-a {
width: 46%;
aspect-ratio: 1;
top: 8%;
left: 4%;
background: rgba(103, 196, 255, 0.55);
}
.orb-b {
width: 30%;
aspect-ratio: 1;
top: 52%;
left: 22%;
background: rgba(241, 219, 167, 0.52);
}
.orb-c {
width: 40%;
aspect-ratio: 1;
top: 18%;
right: 2%;
background: rgba(196, 108, 255, 0.5);
}
.orb-d {
width: 28%;
aspect-ratio: 1;
bottom: 10%;
right: 18%;
background: rgba(255, 82, 217, 0.44);
}
.metrics {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.6rem;
}
.metrics li {
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
padding: 0.72rem;
display: grid;
gap: 0.2rem;
background: rgba(255, 255, 255, 0.03);
}
.metrics li strong {
font-size: 0.95rem;
}
.metrics li span {
color: var(--muted);
font-size: 0.86rem;
}
.stage {
grid-template-columns: 1fr;
align-content: center;
gap: 1.3rem;
}
.stage-cards {
height: 45vh;
position: relative;
}
.stage-card {
position: absolute;
inset: 0;
margin: auto;
width: min(84vw, 700px);
height: min(34vh, 250px);
border-radius: 16px;
display: grid;
place-content: center;
font-size: clamp(1.4rem, 3.8vw, 2.4rem);
font-weight: 800;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.card-one {
background: linear-gradient(140deg, #223c5e, #2d6db4);
}
.card-two {
background: linear-gradient(140deg, #3f2a61, #8d45c9);
}
.card-three {
background: linear-gradient(140deg, #2f3d62, #6c7fca);
}
.note {
color: #dae6f8;
font-weight: 600;
}
body.no-motion .stage-cards {
height: auto;
display: grid;
gap: 0.7rem;
}
body.no-motion .stage-card {
position: relative;
width: 100%;
height: 130px;
}
@media (max-width: 900px) {
.panel {
grid-template-columns: 1fr;
min-height: 86vh;
padding-top: 0.6rem;
padding-bottom: 0.8rem;
}
.visual {
height: 32vh;
}
.stage-cards {
height: 42vh;
}
}if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
const toggle = document.getElementById("motionToggle");
const progressBar = document.getElementById("progressBar");
const sections = Array.from(document.querySelectorAll("[data-animate]"));
const state = {
reduced: window.MotionPreference.prefersReducedMotion(),
lenis: null,
rafId: 0,
animations: [],
};
function setLabel() {
toggle.textContent = state.reduced ? "Enable motion" : "Disable motion";
}
function setProgress(value) {
const clamped = Math.min(1, Math.max(0, value));
progressBar.style.transform = `scaleX(${clamped})`;
}
function updateWindowProgress() {
const max = Math.max(document.body.scrollHeight - window.innerHeight, 1);
setProgress(window.scrollY / max);
}
function resetVisuals() {
sections.forEach((section) => {
section.style.opacity = "1";
section.style.transform = "none";
});
document.querySelectorAll(".reveal").forEach((node) => {
node.style.opacity = "1";
node.style.transform = "none";
});
document.querySelectorAll(".stage-card").forEach((node) => {
node.style.opacity = "1";
node.style.transform = "none";
});
}
function killMotion() {
if (state.rafId) {
cancelAnimationFrame(state.rafId);
state.rafId = 0;
}
if (state.lenis) {
state.lenis.destroy();
state.lenis = null;
}
state.animations.forEach((anim) => anim.kill());
state.animations = [];
if (window.ScrollTrigger) {
window.ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
}
resetVisuals();
}
function register(anim) {
state.animations.push(anim);
return anim;
}
function setupLenis() {
state.lenis = new window.Lenis({
lerp: 0.085,
smoothWheel: true,
wheelMultiplier: 1,
});
state.lenis.on("scroll", (e) => {
setProgress(e.progress || 0);
if (window.ScrollTrigger) window.ScrollTrigger.update();
});
const raf = (time) => {
if (!state.lenis) return;
state.lenis.raf(time);
state.rafId = requestAnimationFrame(raf);
};
state.rafId = requestAnimationFrame(raf);
}
function setupGsap() {
if (!window.gsap || !window.ScrollTrigger) return;
window.gsap.registerPlugin(window.ScrollTrigger);
register(
window.gsap.fromTo(
".intro .reveal",
{ y: 26, opacity: 0 },
{
y: 0,
opacity: 1,
duration: 0.8,
ease: "power3.out",
stagger: 0.08,
}
)
);
sections.forEach((section) => {
if (section.classList.contains("intro")) return;
register(
window.gsap.fromTo(
section.querySelectorAll(".reveal"),
{ y: 52, opacity: 0 },
{
y: 0,
opacity: 1,
duration: 0.9,
ease: "power2.out",
stagger: 0.08,
scrollTrigger: {
trigger: section,
start: "top 78%",
end: "top 38%",
scrub: 0.8,
},
}
)
);
});
const orbs = document.querySelectorAll(".orb");
orbs.forEach((orb, i) => {
register(
window.gsap.to(orb, {
x: i % 2 ? -22 : 20,
y: i % 2 ? 16 : -15,
scale: i % 2 ? 1.06 : 0.94,
repeat: -1,
yoyo: true,
ease: "sine.inOut",
duration: 3.2 + i * 0.7,
})
);
});
register(
window.gsap
.timeline({
scrollTrigger: {
trigger: ".stage",
start: "top top",
end: "+=130%",
scrub: 1,
pin: true,
},
})
.to(".card-one", { xPercent: -35, rotation: -8, scale: 0.9 }, 0)
.to(".card-two", { yPercent: -35, scale: 0.94 }, 0)
.to(".card-three", { xPercent: 35, rotation: 8, scale: 0.9 }, 0)
);
}
function applyMode() {
killMotion();
setLabel();
const noMotion = state.reduced || !window.Lenis || !window.gsap || !window.ScrollTrigger;
document.body.classList.toggle("no-motion", noMotion);
if (noMotion) {
updateWindowProgress();
return;
}
setupLenis();
setupGsap();
}
toggle.addEventListener("click", () => {
state.reduced = !state.reduced;
applyMode();
});
window.addEventListener(
"scroll",
() => {
if (!state.lenis) updateWindowProgress();
},
{ passive: true }
);
window.addEventListener("resize", () => {
if (window.ScrollTrigger) window.ScrollTrigger.refresh();
updateWindowProgress();
});
setLabel();
applyMode();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Demo 01 - Lenis + GSAP Scroll Story</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<a href="../">Back to demos</a>
<div class="controls">
<span class="hint">Scroll Story</span>
<button id="motionToggle" class="toggle"></button>
</div>
</header>
<div class="progress-wrap" aria-hidden="true">
<span id="progressBar" class="progress-bar"></span>
</div>
<main>
<section class="panel intro" data-animate>
<div class="content reveal-group">
<p class="label reveal">Demo 01</p>
<h1 class="reveal">Lenis + GSAP Scroll Story</h1>
<p class="reveal">A stronger baseline for cinematic pacing, section reveals, and scroll rhythm.</p>
<div class="chips reveal">
<span>Lenis smooth flow</span>
<span>GSAP timelines</span>
<span>Reduced-motion safe</span>
</div>
</div>
<div class="visual" aria-hidden="true">
<span class="orb orb-a"></span>
<span class="orb orb-b"></span>
<span class="orb orb-c"></span>
<span class="orb orb-d"></span>
</div>
</section>
<section class="panel" data-animate>
<div class="content reveal-group">
<h2 class="reveal">Signal</h2>
<p class="reveal">Open with high contrast hierarchy, then guide users through chunks of motion and meaning.</p>
<ul class="metrics reveal">
<li><strong>60fps target</strong><span>Compositor-friendly transforms</span></li>
<li><strong>Readable layers</strong><span>Text remains stable under effects</span></li>
<li><strong>Accessible by default</strong><span>Motion can be disabled at any time</span></li>
</ul>
</div>
</section>
<section class="panel stage" data-animate>
<div class="content reveal-group">
<h2 class="reveal">Rhythm</h2>
<p class="reveal">This pinned stage demonstrates chapter transitions and layered card movement.</p>
</div>
<div class="stage-cards" aria-hidden="true">
<article class="stage-card card-one">Chapter A</article>
<article class="stage-card card-two">Chapter B</article>
<article class="stage-card card-three">Chapter C</article>
</div>
</section>
<section class="panel outro" data-animate>
<div class="content reveal-group">
<h2 class="reveal">Launch</h2>
<p class="reveal">Use this file as your production starter for narrative landing pages.</p>
<p class="reveal note">Next step: combine this structure with Demo 09/10 WebGL backgrounds.</p>
</div>
</section>
</main>
<script src="https://cdn.jsdelivr.net/npm/@studio-freight/lenis@1.0.42/bundled/lenis.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
<script src="script.js"></script>
</body>
</html>Lenis + GSAP Scroll Story
Smooth-scrolling narrative sections with timeline triggers.
Source
- Repository:
libs-gen - Original demo id:
01-lenis-gsap-scroll-story
Notes
Smooth-scrolling narrative sections with timeline triggers.