*, *::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":"/vendor/gsap/index.js","gsap/ScrollTrigger":"/vendor/gsap/ScrollTrigger.js","gsap/SplitText":"/vendor/gsap/SplitText.js","gsap/Flip":"/vendor/gsap/Flip.js","gsap/ScrambleTextPlugin":"/vendor/gsap/ScrambleTextPlugin.js","gsap/TextPlugin":"/vendor/gsap/TextPlugin.js","gsap/all":"/vendor/gsap/all.js","gsap/":"/vendor/gsap/","lenis":"/vendor/lenis/dist/lenis.mjs","three":"/vendor/three/build/three.module.js","three/addons/":"/vendor/three/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>