Web Animations Medium
Pinned Scroll Sections
Sections pin in place while multi-step timelines play, driven entirely by scroll position.
Open in Lab
MCP
gsap scrolltrigger pin
Targets: JS HTML
Code
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #070a12;
--panel: #121a2b;
--border: #263249;
--text: #f0f4fb;
--muted: #8a95a8;
--accent: #86e8ff;
--neon-purple: #ae52ff;
--neon-pink: #ff40d6;
}
html {
background: var(--bg);
color: var(--text);
font-family: "Inter", "SF Pro Display", system-ui, sans-serif;
}
body {
overflow-x: hidden;
}
/* โโ Intro โโ */
.intro {
min-height: 70vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
}
.eyebrow {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent);
margin-bottom: 1rem;
}
h1 {
font-size: clamp(2.2rem, 6vw, 4rem);
font-weight: 700;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #fff, var(--accent), var(--neon-purple));
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: clamp(0.9rem, 2vw, 1.05rem);
color: var(--muted);
max-width: 500px;
margin-top: 0.75rem;
line-height: 1.6;
}
/* โโ Pinned sections โโ */
.pinned-section {
position: relative;
min-height: 100vh;
}
.pin-content {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 100vh;
gap: 2rem;
padding: 0 4rem;
align-items: center;
}
/* โโ Left side: steps โโ */
.pin-left {
display: flex;
gap: 2rem;
align-items: flex-start;
padding: 2rem 0;
}
.step-indicators {
display: flex;
flex-direction: column;
gap: 1rem;
padding-top: 0.5rem;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border);
transition: background 0.4s ease, box-shadow 0.4s ease;
}
.step-dot.active {
background: var(--accent);
box-shadow: 0 0 12px rgba(134, 232, 255, 0.4);
}
.pin-text {
position: relative;
}
.step-text {
position: absolute;
top: 0;
left: 0;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.4s ease, transform 0.4s ease;
pointer-events: none;
}
.step-text.active {
position: relative;
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.step-label {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin-bottom: 0.75rem;
}
.step-text h2 {
font-size: clamp(2rem, 4vw, 3.5rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.05;
margin-bottom: 1rem;
-webkit-text-fill-color: var(--text);
}
.step-text p {
font-size: 1rem;
color: var(--muted);
line-height: 1.7;
max-width: 400px;
}
/* โโ Right side: visuals โโ */
.pin-right {
display: flex;
align-items: center;
justify-content: center;
}
/* Visual stage (section 1) */
.visual-stage {
position: relative;
width: 360px;
height: 360px;
}
.vis-element {
position: absolute;
will-change: transform, opacity;
}
.vis-circle {
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), rgba(134, 232, 255, 0.3));
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
}
.vis-square {
width: 80px;
height: 80px;
border-radius: 12px;
background: linear-gradient(135deg, var(--neon-purple), rgba(174, 82, 255, 0.3));
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
}
.vis-triangle {
width: 0;
height: 0;
border-left: 45px solid transparent;
border-right: 45px solid transparent;
border-bottom: 78px solid var(--neon-pink);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
filter: drop-shadow(0 0 16px rgba(255, 64, 214, 0.3));
}
.vis-line {
position: absolute;
height: 2px;
background: var(--border);
border-radius: 1px;
opacity: 0;
will-change: transform, opacity;
}
.vis-line-1 {
width: 120px;
top: 30%;
left: 10%;
}
.vis-line-2 {
width: 80px;
top: 60%;
right: 10%;
}
.vis-line-3 {
width: 160px;
bottom: 20%;
left: 25%;
}
/* Metric bars (section 2) */
.metric-bars {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
max-width: 360px;
}
.metric-bar {
position: relative;
}
.bar-fill {
height: 8px;
border-radius: 4px;
background: var(--color, var(--accent));
width: 0%;
will-change: width;
transition: width 0.05s linear;
}
.bar-label {
display: block;
margin-top: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* โโ Outro โโ */
.outro {
min-height: 50vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
}
.outro h2 {
font-size: clamp(1.8rem, 4vw, 3rem);
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 1.5rem;
-webkit-text-fill-color: var(--text);
}
.btn-back {
display: inline-block;
padding: 0.7rem 2rem;
border-radius: 999px;
border: 1px solid rgba(134, 232, 255, 0.3);
color: var(--accent);
text-decoration: none;
font: 600 0.85rem / 1 "Inter", system-ui, sans-serif;
transition: all 0.25s ease;
}
.btn-back:hover {
background: rgba(134, 232, 255, 0.08);
border-color: var(--accent);
}
/* โโ Reduced motion โโ */
.reduced-motion .vis-element,
.reduced-motion .vis-line,
.reduced-motion .step-text {
opacity: 1 !important;
transform: none !important;
}
.reduced-motion .bar-fill {
transition: none;
}
@media (max-width: 768px) {
.pin-content {
grid-template-columns: 1fr;
padding: 0 1.5rem;
}
.pin-right {
min-height: 300px;
}
.visual-stage {
width: 260px;
height: 260px;
}
}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;
}
function prefersReducedMotion() {
return window.MotionPreference.prefersReducedMotion();
}
function initDemoShell() {
// No-op shim in imported standalone snippets.
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger);
// โโ Demo shell โโ
initDemoShell({
title: "Pinned Scroll Sections",
category: "scroll",
tech: ["gsap", "scrolltrigger", "pin"],
});
// โโ Lenis โโ
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
const reduced = prefersReducedMotion();
if (reduced) document.documentElement.classList.add("reduced-motion");
window.addEventListener("motion-preference", (e) => {
document.documentElement.classList.toggle("reduced-motion", e.detail.reduced);
ScrollTrigger.refresh();
});
const dur = (d) => (reduced ? 0 : d);
// โโ Helper: update step text + dots โโ
function setActiveStep(section, stepIndex) {
const dots = section.querySelectorAll(".step-dot");
const texts = section.querySelectorAll(".step-text");
dots.forEach((dot, i) => dot.classList.toggle("active", i === stepIndex));
texts.forEach((text, i) => text.classList.toggle("active", i === stepIndex));
}
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Section 1: Features (Design / Animate / Polish)
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const s1 = document.getElementById("section-features");
const tl1 = gsap.timeline({
scrollTrigger: {
trigger: s1,
pin: true,
scrub: 1,
start: "top top",
end: "+=300%",
},
});
// Step 0 โ 1 transition (Design โ Animate)
tl1
// Step 0: Circle appears
.to("#vis-circle", { opacity: 1, scale: 1, duration: dur(0.3) }, 0)
.to(".vis-line-1", { opacity: 1, duration: dur(0.2) }, 0.05)
// Step 0 โ 1: Circle moves, square enters
.call(() => setActiveStep(s1, 1), [], 0.33)
.to("#vis-circle", { x: -80, y: -60, scale: 0.8, duration: dur(0.3) }, 0.33)
.to("#vis-square", { opacity: 1, scale: 1, rotation: 45, duration: dur(0.3) }, 0.33)
.to(".vis-line-1", { scaleX: 1.5, x: -20, background: "var(--accent)", duration: dur(0.2) }, 0.35)
.to(".vis-line-2", { opacity: 1, duration: dur(0.2) }, 0.38)
// Step 1 โ 2: All elements arrange, triangle enters
.call(() => setActiveStep(s1, 2), [], 0.66)
.to("#vis-circle", { x: -100, y: -80, scale: 0.65, duration: dur(0.3) }, 0.66)
.to("#vis-square", { x: 80, y: -50, rotation: 90, scale: 0.7, duration: dur(0.3) }, 0.66)
.to("#vis-triangle", { opacity: 1, y: 60, duration: dur(0.3) }, 0.66)
.to(".vis-line-3", { opacity: 1, duration: dur(0.2) }, 0.7)
.to(
[".vis-line-1", ".vis-line-2", ".vis-line-3"],
{
background: "var(--accent)",
opacity: 0.6,
duration: dur(0.2),
},
0.72
);
// Initialize step 0
setActiveStep(s1, 0);
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Section 2: Metrics (Performance / Accessibility / Bundle)
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const s2 = document.getElementById("section-metrics");
const bar1 = s2.querySelector("#bar-1 .bar-fill");
const bar2 = s2.querySelector("#bar-2 .bar-fill");
const bar3 = s2.querySelector("#bar-3 .bar-fill");
const tl2 = gsap.timeline({
scrollTrigger: {
trigger: s2,
pin: true,
scrub: 1,
start: "top top",
end: "+=300%",
onUpdate: (self) => {
const p = self.progress;
// Bar 1 fills in step 0 (0โ0.33)
const b1 = gsap.utils.clamp(0, 1, p / 0.33);
bar1.style.width = `${b1 * 95}%`;
// Bar 2 fills in step 1 (0.33โ0.66)
const b2 = gsap.utils.clamp(0, 1, (p - 0.33) / 0.33);
bar2.style.width = `${b2 * 100}%`;
// Bar 3 fills in step 2 (0.66โ1)
const b3 = gsap.utils.clamp(0, 1, (p - 0.66) / 0.34);
bar3.style.width = `${b3 * 88}%`;
// Step indicators
if (p < 0.33) setActiveStep(s2, 0);
else if (p < 0.66) setActiveStep(s2, 1);
else setActiveStep(s2, 2);
},
},
});
setActiveStep(s2, 0);
// โโ Intro animations โโ
if (!reduced) {
gsap.set(".intro .eyebrow", { opacity: 0, y: 20 });
gsap.set(".intro h1", { opacity: 0, y: 40 });
gsap.set(".intro .subtitle", { opacity: 0, y: 25 });
gsap
.timeline({ defaults: { ease: "expo.out" } })
.to(".intro .eyebrow", { opacity: 1, y: 0, duration: 0.7, delay: 0.3 })
.to(".intro h1", { opacity: 1, y: 0, duration: 0.9 }, "-=0.4")
.to(".intro .subtitle", { opacity: 1, y: 0, duration: 0.7 }, "-=0.5");
}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pinned Scroll Sections โ stealthisdesign</title>
<link rel="stylesheet" href="style.css">
<script type="importmap">{"imports":{"gsap":"https://esm.sh/gsap@3.13.0","gsap/ScrollTrigger":"https://esm.sh/gsap@3.13.0/ScrollTrigger","gsap/SplitText":"https://esm.sh/gsap@3.13.0/SplitText","gsap/Flip":"https://esm.sh/gsap@3.13.0/Flip","gsap/ScrambleTextPlugin":"https://esm.sh/gsap@3.13.0/ScrambleTextPlugin","gsap/TextPlugin":"https://esm.sh/gsap@3.13.0/TextPlugin","gsap/all":"https://esm.sh/gsap@3.13.0/all","gsap/":"https://esm.sh/gsap@3.13.0/","lenis":"https://esm.sh/lenis@1.1.13/dist/lenis.mjs","three":"https://esm.sh/three@0.171.0","three/addons/":"https://esm.sh/three@0.171.0/examples/jsm/"}}</script>
<style>html.lenis,
html.lenis body {
height: auto;
}
.lenis:not(.lenis-autoToggle).lenis-stopped {
overflow: clip;
}
.lenis [data-lenis-prevent],
.lenis [data-lenis-prevent-wheel],
.lenis [data-lenis-prevent-touch] {
overscroll-behavior: contain;
}
.lenis.lenis-smooth iframe {
pointer-events: none;
}
.lenis.lenis-autoToggle {
transition-property: overflow;
transition-duration: 1ms;
transition-behavior: allow-discrete;
}</style>
</head>
<body>
<!-- Intro -->
<section class="intro">
<span class="eyebrow">Demo 04</span>
<h1>Pinned Scroll Sections</h1>
<p class="subtitle">Each section pins in place while a multi-step timeline plays, driven entirely by scroll position.</p>
</section>
<!-- Pinned Section 1: Features -->
<section class="pinned-section" id="section-features">
<div class="pin-content">
<div class="pin-left">
<div class="step-indicators">
<div class="step-dot active" data-step="0"></div>
<div class="step-dot" data-step="1"></div>
<div class="step-dot" data-step="2"></div>
</div>
<div class="pin-text">
<div class="step-text active" data-step="0">
<span class="step-label">Step 1 of 3</span>
<h2>Design</h2>
<p>Every animation begins with intention. The design phase establishes timing, easing, and choreography before a single line of code is written.</p>
</div>
<div class="step-text" data-step="1">
<span class="step-label">Step 2 of 3</span>
<h2>Animate</h2>
<p>ScrollTrigger scrubs through GSAP timelines at the pace of the user's scroll, creating a sense of direct manipulation and control.</p>
</div>
<div class="step-text" data-step="2">
<span class="step-label">Step 3 of 3</span>
<h2>Polish</h2>
<p>The final pass adds micro-interactions, easing refinements, and reduced-motion alternatives to ensure a premium experience for everyone.</p>
</div>
</div>
</div>
<div class="pin-right">
<div class="visual-stage">
<div class="vis-element vis-circle" id="vis-circle"></div>
<div class="vis-element vis-square" id="vis-square"></div>
<div class="vis-element vis-triangle" id="vis-triangle"></div>
<div class="vis-line vis-line-1"></div>
<div class="vis-line vis-line-2"></div>
<div class="vis-line vis-line-3"></div>
</div>
</div>
</div>
</section>
<!-- Pinned Section 2: Metrics -->
<section class="pinned-section" id="section-metrics">
<div class="pin-content">
<div class="pin-left">
<div class="step-indicators">
<div class="step-dot active" data-step="0"></div>
<div class="step-dot" data-step="1"></div>
<div class="step-dot" data-step="2"></div>
</div>
<div class="pin-text">
<div class="step-text active" data-step="0">
<span class="step-label">Performance</span>
<h2>60fps</h2>
<p>Every animation targets a smooth 60 frames per second, with GPU-accelerated transforms and careful paint avoidance.</p>
</div>
<div class="step-text" data-step="1">
<span class="step-label">Accessibility</span>
<h2>100%</h2>
<p>All demos respect prefers-reduced-motion, include keyboard navigation, and maintain WCAG AA contrast ratios.</p>
</div>
<div class="step-text" data-step="2">
<span class="step-label">Bundle Size</span>
<h2><50kb</h2>
<p>Tree-shaking and careful dependency management keep animation code lean and loading times fast.</p>
</div>
</div>
</div>
<div class="pin-right">
<div class="metric-bars">
<div class="metric-bar" id="bar-1">
<div class="bar-fill" style="--color: #86e8ff;"></div>
<span class="bar-label">Performance</span>
</div>
<div class="metric-bar" id="bar-2">
<div class="bar-fill" style="--color: #ae52ff;"></div>
<span class="bar-label">Accessibility</span>
</div>
<div class="metric-bar" id="bar-3">
<div class="bar-fill" style="--color: #ff40d6;"></div>
<span class="bar-label">Efficiency</span>
</div>
</div>
</div>
</div>
</section>
<!-- Outro -->
<section class="outro">
<h2>Scroll-driven storytelling.</h2>
<a href="/" class="btn-back">Back to Showcase</a>
</section>
<script type="module" src="script.js"></script>
</body>
</html>Pinned Scroll Sections
Sections pin in place while multi-step timelines play, driven entirely by scroll position.
Source
- Repository:
libs-genclaude - Original demo id:
04-pinned-timeline
Notes
Sections pin in place while multi-step timelines play, driven entirely by scroll position.