Web Animations Easy
Scroll Progress Indicators
Three simultaneous scroll indicators: top bar, circular SVG progress, and section dot navigator.
Open in Lab
MCP
gsap lenis scrolltrigger svg
Targets: JS HTML
Code
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #070a12;
--text: #f0f4fb;
--muted: #8a95a8;
--accent: #86e8ff;
--neon-purple: #ae52ff;
--border: #263249;
}
html {
background: var(--bg);
color: var(--text);
font-family: "Inter", "SF Pro Display", system-ui, sans-serif;
}
body {
overflow-x: hidden;
}
/* Top bar */
.progress-top {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
z-index: 100;
background: rgba(255, 255, 255, 0.05);
}
.progress-top-fill {
height: 100%;
width: 100%;
background: linear-gradient(90deg, var(--accent), var(--neon-purple));
transform: scaleX(0);
transform-origin: left;
will-change: transform;
}
/* Circular indicator */
.progress-circle {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 100;
width: 48px;
height: 48px;
}
.progress-circle svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.progress-circle .track {
fill: none;
stroke: var(--border);
stroke-width: 3;
}
.progress-circle .fill {
fill: none;
stroke: var(--accent);
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 125.66;
stroke-dashoffset: 125.66;
will-change: stroke-dashoffset;
transition: stroke-dashoffset 0.05s linear;
}
.progress-pct {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
font-weight: 700;
color: var(--accent);
}
/* Section dots */
.section-dots {
position: fixed;
right: 2rem;
top: 50%;
transform: translateY(-50%);
z-index: 100;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border);
border: none;
cursor: pointer;
transition: background 0.3s, box-shadow 0.3s, transform 0.3s;
}
.dot:hover {
background: rgba(134, 232, 255, 0.4);
}
.dot.active {
background: var(--accent);
box-shadow: 0 0 10px rgba(134, 232, 255, 0.4);
transform: scale(1.3);
}
/* Sections */
.section {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.content {
max-width: 600px;
}
.eyebrow {
display: inline-block;
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, 5vw, 3.5rem);
font-weight: 700;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #fff, var(--accent));
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 1rem;
}
h2 {
font-size: clamp(1.8rem, 4vw, 2.8rem);
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 1rem;
}
.body-text {
font-size: 1rem;
color: var(--muted);
line-height: 1.7;
margin-bottom: 1rem;
}
code {
background: rgba(134, 232, 255, 0.1);
color: var(--accent);
padding: 0.12rem 0.4rem;
border-radius: 4px;
font-size: 0.85rem;
}
.btn-back {
display: inline-block;
margin-top: 1.5rem;
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;
}
.btn-back:hover {
background: rgba(134, 232, 255, 0.08);
border-color: var(--accent);
}
.reduced-motion .progress-top-fill {
transition: none;
}
.reduced-motion .progress-circle .fill {
transition: none;
}
@media (max-width: 640px) {
.section-dots {
right: 0.75rem;
}
.progress-circle {
bottom: 1rem;
right: 0.75rem;
width: 40px;
height: 40px;
}
}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);
initDemoShell({
title: "Scroll Progress Indicators",
category: "scroll",
tech: ["gsap", "scrolltrigger", "svg"],
});
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");
// Refs
const topFill = document.querySelector(".progress-top-fill");
const circleFill = document.querySelector(".progress-circle .fill");
const pctText = document.getElementById("progress-pct");
const dots = document.querySelectorAll(".dot");
const sections = document.querySelectorAll(".section");
const circumference = 2 * Math.PI * 20; // r=20
// Overall page progress
ScrollTrigger.create({
trigger: document.body,
start: "top top",
end: "bottom bottom",
onUpdate: (self) => {
const p = self.progress;
// Top bar
topFill.style.transform = `scaleX(${p})`;
// Circle
const offset = circumference * (1 - p);
circleFill.style.strokeDashoffset = offset;
// Percentage text
pctText.textContent = `${Math.round(p * 100)}%`;
},
});
// Per-section triggers for dots
sections.forEach((section, i) => {
ScrollTrigger.create({
trigger: section,
start: "top center",
end: "bottom center",
onEnter: () => setActiveDot(i),
onEnterBack: () => setActiveDot(i),
});
});
function setActiveDot(index) {
dots.forEach((d, i) => d.classList.toggle("active", i === index));
}
// Dot click → scroll to section
dots.forEach((dot) => {
dot.addEventListener("click", () => {
const idx = parseInt(dot.dataset.section, 10);
const target = sections[idx];
if (target) {
lenis.scrollTo(target, { offset: 0, duration: 1.2 });
}
});
});
// Intro animation
if (!reduced) {
gsap.set('.section[data-index="0"] .eyebrow', { opacity: 0, y: 20 });
gsap.set('.section[data-index="0"] h1', { opacity: 0, y: 40 });
gsap.set('.section[data-index="0"] .body-text', { opacity: 0, y: 25 });
gsap
.timeline({ defaults: { ease: "expo.out" } })
.to('.section[data-index="0"] .eyebrow', { opacity: 1, y: 0, duration: 0.7, delay: 0.3 })
.to('.section[data-index="0"] h1', { opacity: 1, y: 0, duration: 0.9 }, "-=0.4")
.to('.section[data-index="0"] .body-text', { opacity: 1, y: 0, duration: 0.7 }, "-=0.5");
}
// Section content reveals
sections.forEach((section, i) => {
if (i === 0) return;
const h = section.querySelector("h2");
const p = section.querySelector(".body-text");
if (!reduced && h) {
gsap.set(h, { opacity: 0, y: 40 });
gsap.to(h, {
opacity: 1,
y: 0,
duration: 0.8,
ease: "expo.out",
scrollTrigger: { trigger: section, start: "top 70%" },
});
}
if (!reduced && p) {
gsap.set(p, { opacity: 0, y: 25 });
gsap.to(p, {
opacity: 1,
y: 0,
duration: 0.7,
ease: "expo.out",
scrollTrigger: { trigger: section, start: "top 65%" },
});
}
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scroll Progress Indicators — 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>
<!-- Fixed indicators -->
<div class="progress-top" id="progress-top"><div class="progress-top-fill"></div></div>
<div class="progress-circle" id="progress-circle">
<svg viewBox="0 0 48 48"><circle class="track" cx="24" cy="24" r="20"/><circle class="fill" cx="24" cy="24" r="20"/></svg>
<span class="progress-pct" id="progress-pct">0%</span>
</div>
<nav class="section-dots" id="section-dots">
<button class="dot active" data-section="0"></button>
<button class="dot" data-section="1"></button>
<button class="dot" data-section="2"></button>
<button class="dot" data-section="3"></button>
<button class="dot" data-section="4"></button>
</nav>
<!-- Content sections -->
<section class="section" data-index="0">
<div class="content">
<span class="eyebrow">Demo 06</span>
<h1>Scroll Progress Indicators</h1>
<p class="body-text">Three indicator styles running simultaneously: top bar, circular SVG, and section dots. All driven by ScrollTrigger progress values.</p>
</div>
</section>
<section class="section" data-index="1">
<div class="content">
<h2>Linear Bar</h2>
<p class="body-text">The top progress bar uses <code>scaleX</code> driven by overall page scroll progress. It's the simplest indicator — a single CSS transform.</p>
</div>
</section>
<section class="section" data-index="2">
<div class="content">
<h2>Circular SVG</h2>
<p class="body-text">The circle uses SVG <code>stroke-dashoffset</code> to draw the ring progressively. The percentage text updates in real-time with scroll position.</p>
</div>
</section>
<section class="section" data-index="3">
<div class="content">
<h2>Section Dots</h2>
<p class="body-text">Each dot represents a content section. The active dot highlights based on which section's ScrollTrigger is currently in view. Click a dot to jump to that section.</p>
</div>
</section>
<section class="section" data-index="4">
<div class="content">
<h2>Combined Power</h2>
<p class="body-text">When used together, these indicators give users multiple spatial cues about their position in the content. Each serves a different cognitive need: linear for absolute progress, circular for at-a-glance percentage, dots for structural navigation.</p>
<a href="/" class="btn-back">Back to Showcase</a>
</div>
</section>
<script type="module" src="script.js"></script>
</body>
</html>Scroll Progress Indicators
Three simultaneous scroll indicators: top bar, circular SVG progress, and section dot navigator.
Source
- Repository:
libs-genclaude - Original demo id:
06-scroll-progress
Notes
Three simultaneous scroll indicators: top bar, circular SVG progress, and section dot navigator.