Back to Gallery f02

Scroll Progress Indicator

FREE

Four scroll progress visualization styles using GSAP ScrollTrigger. Includes animated bar, circle, rail track, and percentage counter indicators.

ScrollTrigger scroll-revealscrubprogress-tracking beginner

About This Effect

A scroll progress visualization system that displays reading position through animated bars, circles, and indicators. Multiple display styles include a top-of-page progress bar, a circular progress ring, and section-specific progress indicators. The progress value is tied to ScrollTrigger for precise mapping between scroll position and visual feedback.

What's Included

  • Multiple display styles: linear bar, circular ring, and numeric percentage
  • Top-of-page sticky progress bar with configurable height and color
  • Circular progress ring with SVG stroke-dashoffset animation
  • Section-level progress tracking for multi-section pages
  • ScrollTrigger scrub for smooth, reversible progress updates
  • CSS custom properties for easy theming and customization
  • Responsive sizing and positioning across breakpoints
  • Accessible progress indication with ARIA attributes

Perfect For

  • Blog posts and long-form article reading indicators
  • Documentation pages with section progress tracking
  • Landing page scroll depth visualization
  • Tutorial and course content progress bars
  • Single-page application scroll position feedback

How It Works

ScrollTrigger maps the page (or section) scroll progress to a 0–1 value. For the linear bar, this value sets the scaleX transform of a fixed-position element. For the circular ring, the progress maps to SVG stroke-dashoffset, progressively drawing the circle. GSAP's scrub feature ensures the progress updates smoothly and reverses naturally when scrolling up.

Plugins ScrollTrigger
Difficulty Beginner
Includes HTML + JS + CSS source, documentation, lifetime updates
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8">
		<meta name="viewport" content="width=device-width, initial-scale=1.0">
		<meta name="robots" content="noindex, nofollow">
		<title>Scroll Progress Indicator Demo | GSAP Vault</title>
		<link rel="stylesheet" href="assets/style.css">
		<link rel="preconnect" href="https://fonts.googleapis.com">
		<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
	</head>
	<body>
		<!-- Global Progress Bar (top of page) -->
		<div class="progress-bar" data-progress-style="bar" aria-hidden="true">
			<div class="progress-bar__fill"></div>
		</div>

		<!-- Hero fills viewport to demonstrate scroll animations -->
		<section class="hero" data-thumbnail-target>
			<div class="hero-content">
				<span class="page-badge">ScrollTrigger</span>
				<h2 class="hero-title">Scroll Progress</h2>
				<p class="hero-subtitle">Four visualization styles for tracking page scroll position.</p>
				<div class="scroll-hint">
					<span>Scroll to see progress</span>
					<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
						<path d="M12 5v14M5 12l7 7 7-7"/>
					</svg>
				</div>
			</div>
		</section>

		<main class="container">
			<!-- Example 1: Linear Bar -->
			<section class="example">
				<div class="example-header">
					<span class="example-number">01</span>
					<h2 class="example-title">Linear Bar</h2>
				</div>
				<div class="example-content">
					<p class="example-instruction">A horizontal progress bar at the top of the viewport. Uses <code>scaleX</code> for smooth GPU-accelerated animation.</p>
					<div class="demo-area">
						<div class="prose">
							<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
							<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
							<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.</p>
						</div>
					</div>
				</div>
			</section>

			<!-- Example 2: Circular Ring -->
			<section class="example">
				<div class="example-header">
					<span class="example-number">02</span>
					<h2 class="example-title">Circular Ring</h2>
				</div>
				<div class="example-content">
					<p class="example-instruction">An SVG circle that fills using <code>stroke-dashoffset</code>. Optionally shows percentage text.</p>

					<!-- Circular progress indicator for this section -->
					<div class="progress-circle" data-progress-style="circle" aria-hidden="true">
						<svg class="progress-circle__svg" viewBox="0 0 60 60">
							<circle class="progress-circle__bg" cx="30" cy="30" r="25"/>
							<circle class="progress-circle__fill" cx="30" cy="30" r="25"/>
						</svg>
						<span class="progress-circle__text">0%</span>
					</div>

					<div class="demo-area">
						<div class="prose">
							<p>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
							<p>Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.</p>
							<p>Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur.</p>
						</div>
					</div>
				</div>
			</section>

			<!-- Example 3: Side Rail -->
			<section class="example">
				<div class="example-header">
					<span class="example-number">03</span>
					<h2 class="example-title">Side Rail</h2>
				</div>
				<div class="example-content">
					<p class="example-instruction">A vertical bar on the left or right edge. Uses <code>scaleY</code> with <code>transform-origin: top</code>.</p>

					<!-- Rail progress indicator -->
					<div class="progress-rail progress-rail--right" data-progress-style="rail" aria-hidden="true">
						<div class="progress-rail__fill"></div>
					</div>

					<div class="demo-area">
						<div class="prose">
							<p>At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.</p>
							<p>Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.</p>
							<p>Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.</p>
						</div>
					</div>
				</div>
			</section>

			<!-- Example 4: Percentage Counter -->
			<section class="example">
				<div class="example-header">
					<span class="example-number">04</span>
					<h2 class="example-title">Percentage Counter</h2>
				</div>
				<div class="example-content">
					<p class="example-instruction">A numeric display using <code>gsap.quickTo()</code> for smooth number interpolation.</p>

					<!-- Counter progress indicator -->
					<div class="progress-counter" data-progress-style="counter" aria-hidden="true">
						<span class="progress-counter__value">0</span>
						<span class="progress-counter__symbol">%</span>
					</div>

					<div class="demo-area">
						<div class="prose">
							<p>Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.</p>
							<p>Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.</p>
							<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p>
						</div>
					</div>
				</div>
			</section>

			<!-- Use Cases Section -->
			<section class="example example--info">
				<div class="example-header">
					<span class="example-number">
						<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
							<path d="M12 2L2 7l10 5 10-5-10-5z"/>
							<path d="M2 17l10 5 10-5"/>
							<path d="M2 12l10 5 10-5"/>
						</svg>
					</span>
					<h2 class="example-title">When to Use Each Style</h2>
				</div>
				<div class="example-content">
					<div class="info-grid">
						<div class="info-card info-card--positive">
							<h3>Best Practices</h3>
							<ul>
								<li><strong>Bar:</strong> Blog posts, documentation, long-form content</li>
								<li><strong>Circle:</strong> Landing pages, minimal designs, corner placement</li>
								<li><strong>Rail:</strong> Editorial sites, vertical emphasis, full-height</li>
								<li><strong>Counter:</strong> Technical docs, precise feedback, data-driven</li>
							</ul>
						</div>
						<div class="info-card info-card--neutral">
							<h3>Accessibility Notes</h3>
							<ul>
								<li>All indicators use <code>aria-hidden="true"</code> (decorative)</li>
								<li>CSS provides static 100% state for reduced motion</li>
								<li>No keyboard interaction required (passive visualization)</li>
								<li>Works with any smooth scroll library or native scroll</li>
							</ul>
						</div>
					</div>
				</div>
			</section>
		</main>

		<!-- Scripts -->
		<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
		<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
		<script src="assets/script.js"></script>
	</body>
</html>
gsap.registerPlugin(ScrollTrigger);

window.addEventListener('DOMContentLoaded', function handleDOMLoaded() {

	/* OPTIONAL: Lenis - Remove if not using smooth scroll */
	let lenis = null;
	if (typeof Lenis !== 'undefined') {
		lenis = new Lenis({ autoRaf: true });
	}
	/* END OPTIONAL: Lenis */

	const progressInstances = [];

	/* CORE: ScrollProgress Class - Required */
	class ScrollProgress {
		constructor(element, options = {}) {
			this.element = element;
			this.style = options.style || element.dataset.progressStyle || 'bar';
			this.position = options.position || element.dataset.progressPosition || null;
			this.trigger = null;
			this.quickTo = null;
			this.init();
		}

		init() {
			switch (this.style) {
				case 'bar':
					this.initBar();
					break;
				case 'circle':
					this.initCircle();
					break;
				case 'rail':
					this.initRail();
					break;
				case 'counter':
					this.initCounter();
					break;
			}
		}

		/* STYLE: Linear Bar */
		initBar() {
			const fill = this.element.querySelector('.progress-bar__fill');
			if (!fill) return;

			this.trigger = ScrollTrigger.create({
				trigger: document.documentElement,
				start: 'top top',
				end: 'bottom bottom',
				scrub: 0.5,
				onUpdate: function(self) {
					gsap.set(fill, { scaleX: self.progress });
				}
			});
		}

		/* STYLE: Circular Ring */
		initCircle() {
			const fillPath = this.element.querySelector('.progress-circle__fill');
			const textEl = this.element.querySelector('.progress-circle__text');
			if (!fillPath) return;

			const circumference = 157; // 2 * PI * 25 (radius)

			this.trigger = ScrollTrigger.create({
				trigger: document.documentElement,
				start: 'top top',
				end: 'bottom bottom',
				scrub: 0.5,
				onUpdate: function(self) {
					const offset = circumference * (1 - self.progress);
					gsap.set(fillPath, { strokeDashoffset: offset });
					if (textEl) {
						textEl.textContent = Math.round(self.progress * 100) + '%';
					}
				}
			});
		}

		/* STYLE: Side Rail */
		initRail() {
			const fill = this.element.querySelector('.progress-rail__fill');
			if (!fill) return;

			this.trigger = ScrollTrigger.create({
				trigger: document.documentElement,
				start: 'top top',
				end: 'bottom bottom',
				scrub: 0.5,
				onUpdate: function(self) {
					gsap.set(fill, { scaleY: self.progress });
				}
			});
		}

		/* STYLE: Percentage Counter */
		initCounter() {
			const valueEl = this.element.querySelector('.progress-counter__value');
			if (!valueEl) return;

			const obj = { value: 0 };
			this.quickTo = gsap.quickTo(obj, 'value', {
				duration: 0.3,
				ease: 'power2.out',
				onUpdate: function() {
					valueEl.textContent = Math.round(obj.value);
				}
			});

			const quickTo = this.quickTo;
			this.trigger = ScrollTrigger.create({
				trigger: document.documentElement,
				start: 'top top',
				end: 'bottom bottom',
				onUpdate: function(self) {
					quickTo(self.progress * 100);
				}
			});
		}

		destroy() {
			if (this.trigger) {
				this.trigger.kill();
			}
		}
	}
	/* END CORE */

	const ctx = gsap.context(function() {
		const mm = gsap.matchMedia();

		mm.add('(prefers-reduced-motion: no-preference)', function() {

			/* Initialize all progress indicators */
			document.querySelectorAll('[data-progress-style]').forEach(function(element) {
				const progress = new ScrollProgress(element);
				progressInstances.push(progress);
			});

			return function() {
				progressInstances.forEach(function(instance) {
					instance.destroy();
				});
				progressInstances.length = 0;
			};
		});

		mm.add('(prefers-reduced-motion: reduce)', function() {
			// CSS handles showing 100% state
		});
	});

	window.ScrollProgress = ScrollProgress;
	window.gsapContext = ctx;

	window.addEventListener('beforeunload', function() {
		if (ctx) ctx.kill();
		if (lenis) lenis.destroy();
	});
});
gsap.registerPlugin(ScrollTrigger),window.addEventListener("DOMContentLoaded",function(){let t=null;"undefined"!=typeof Lenis&&(t=new Lenis({autoRaf:!0}));const e=[];class r{constructor(t,e={}){this.element=t,this.style=e.style||t.dataset.progressStyle||"bar",this.position=e.position||t.dataset.progressPosition||null,this.trigger=null,this.quickTo=null,this.init()}init(){switch(this.style){case"bar":this.initBar();break;case"circle":this.initCircle();break;case"rail":this.initRail();break;case"counter":this.initCounter()}}initBar(){const t=this.element.querySelector(".progress-bar__fill");t&&(this.trigger=ScrollTrigger.create({trigger:document.documentElement,start:"top top",end:"bottom bottom",scrub:.5,onUpdate:function(e){gsap.set(t,{scaleX:e.progress})}}))}initCircle(){const t=this.element.querySelector(".progress-circle__fill"),e=this.element.querySelector(".progress-circle__text");if(!t)return;this.trigger=ScrollTrigger.create({trigger:document.documentElement,start:"top top",end:"bottom bottom",scrub:.5,onUpdate:function(r){const o=157*(1-r.progress);gsap.set(t,{strokeDashoffset:o}),e&&(e.textContent=Math.round(100*r.progress)+"%")}})}initRail(){const t=this.element.querySelector(".progress-rail__fill");t&&(this.trigger=ScrollTrigger.create({trigger:document.documentElement,start:"top top",end:"bottom bottom",scrub:.5,onUpdate:function(e){gsap.set(t,{scaleY:e.progress})}}))}initCounter(){const t=this.element.querySelector(".progress-counter__value");if(!t)return;const e={value:0};this.quickTo=gsap.quickTo(e,"value",{duration:.3,ease:"power2.out",onUpdate:function(){t.textContent=Math.round(e.value)}});const r=this.quickTo;this.trigger=ScrollTrigger.create({trigger:document.documentElement,start:"top top",end:"bottom bottom",onUpdate:function(t){r(100*t.progress)}})}destroy(){this.trigger&&this.trigger.kill()}}const o=gsap.context(function(){const t=gsap.matchMedia();t.add("(prefers-reduced-motion: no-preference)",function(){return document.querySelectorAll("[data-progress-style]").forEach(function(t){const o=new r(t);e.push(o)}),function(){e.forEach(function(t){t.destroy()}),e.length=0}}),t.add("(prefers-reduced-motion: reduce)",function(){})});window.ScrollProgress=r,window.gsapContext=o,window.addEventListener("beforeunload",function(){o&&o.kill(),t&&t.destroy()})});
* {
	margin: 0;
	padding: 0;
	box-sizing: border-box;
}

:root {
	--bg: #050505;
	--surface: #0f0f0f;
	--card: #141414;
	--text: #f0f0f0;
	--text-muted: #888;
	--accent: #c8ff00;
	--accent-dim: rgba(200, 255, 0, 0.1);
	--border: rgba(255, 255, 255, 0.08);
	--border-hover: rgba(255, 255, 255, 0.15);
	--radius: 12px;
	--radius-lg: 16px;
}

html {
	scroll-behavior: smooth;
}

body {
	background: var(--bg);
	color: var(--text);
	font-family: 'Space Grotesk', system-ui, sans-serif;
	line-height: 1.6;
	-webkit-font-smoothing: antialiased;
	-moz-osx-font-smoothing: grayscale;
}

::selection {
	background: var(--accent);
	color: var(--bg);
}

/* ============================================
   HERO
   ============================================ */
.hero {
	min-height: 100vh;
	display: flex;
	align-items: center;
	justify-content: center;
	text-align: center;
	padding: 2rem;
	position: relative;
	background: radial-gradient(ellipse at center, var(--surface) 0%, var(--bg) 70%);
}

.hero-content {
	max-width: 600px;
}

.hero-title {
	font-family: 'Syne', sans-serif;
	font-size: clamp(2.5rem, 10vw, 4.5rem);
	font-weight: 700;
	letter-spacing: -0.03em;
	line-height: 1.05;
	margin-bottom: 1rem;
}

.hero-subtitle {
	font-size: 1.25rem;
	color: var(--text-muted);
	margin-bottom: 3rem;
}

.scroll-hint {
	display: flex;
	flex-direction: column;
	align-items: center;
	gap: 0.75rem;
	color: var(--text-muted);
	font-size: 0.85rem;
	animation: bounce 2s ease-in-out infinite;
}

.scroll-hint svg {
	color: var(--accent);
}

@keyframes bounce {
	0%, 100% { transform: translateY(0); }
	50% { transform: translateY(8px); }
}

/* ============================================
   CONTAINER
   ============================================ */
.container {
	max-width: 900px;
	margin: 0 auto;
	padding: 4rem 2rem 8rem;
}

/* ============================================
   PAGE BADGE
   ============================================ */
.page-badge {
	display: inline-block;
	font-family: 'JetBrains Mono', monospace;
	font-size: 0.7rem;
	font-weight: 500;
	text-transform: uppercase;
	letter-spacing: 0.15em;
	color: var(--accent);
	background: var(--accent-dim);
	padding: 0.4rem 0.8rem;
	border-radius: 100px;
	margin-bottom: 1.5rem;
}

/* ============================================
   EXAMPLE SECTIONS
   ============================================ */
.example {
	margin-bottom: 6rem;
}

.example:last-child {
	margin-bottom: 0;
}

.example-header {
	display: flex;
	align-items: center;
	gap: 1rem;
	margin-bottom: 1.5rem;
}

.example-number {
	font-family: 'JetBrains Mono', monospace;
	font-size: 0.75rem;
	color: var(--accent);
	letter-spacing: 0.1em;
}

.example-title {
	font-size: 0.8rem;
	font-weight: 500;
	text-transform: uppercase;
	letter-spacing: 0.12em;
	color: var(--text-muted);
}

.example-content {
	margin-bottom: 1.5rem;
}

.example-instruction {
	font-size: 0.95rem;
	color: var(--text-muted);
	margin-bottom: 1.5rem;
}

.example-instruction code {
	font-family: 'JetBrains Mono', monospace;
	font-size: 0.85em;
	background: var(--surface);
	padding: 0.2em 0.4em;
	border-radius: 4px;
}

/* ============================================
   DEMO AREA
   ============================================ */
.demo-area {
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius-lg);
	padding: 2.5rem;
	position: relative;
}

.demo-area::before {
	content: '';
	position: absolute;
	inset: 0;
	background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
	opacity: 0.03;
	pointer-events: none;
	border-radius: inherit;
}

/* ============================================
   PROSE CONTENT
   ============================================ */
.prose {
	display: flex;
	flex-direction: column;
	gap: 1.25rem;
}

.prose p {
	font-size: 0.95rem;
	line-height: 1.7;
	color: var(--text-muted);
}

/* ============================================
   CODE BLOCKS
   ============================================ */
.code-block {
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	overflow: hidden;
	margin-top: 1.5rem;
}

.code-block pre {
	padding: 1.25rem;
	overflow-x: auto;
}

.code-block code {
	font-family: 'JetBrains Mono', monospace;
	font-size: 0.8rem;
	line-height: 1.7;
	color: var(--text-muted);
}

/* ============================================
   SCROLL PROGRESS - LINEAR BAR
   ============================================ */
.progress-bar {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	height: 3px;
	background: var(--border);
	z-index: 1000;
}

.progress-bar__fill {
	height: 100%;
	background: var(--accent);
	transform-origin: left;
	transform: scaleX(0);
	will-change: transform;
}

/* Bottom position variant */
.progress-bar--bottom {
	top: auto;
	bottom: 0;
}

/* Thicker variant */
.progress-bar--thick {
	height: 5px;
}

/* ============================================
   SCROLL PROGRESS - CIRCULAR RING
   ============================================ */
.progress-circle {
	position: fixed;
	bottom: 2rem;
	right: 2rem;
	width: 60px;
	height: 60px;
	z-index: 1000;
}

.progress-circle__svg {
	transform: rotate(-90deg);
	width: 100%;
	height: 100%;
}

.progress-circle__bg {
	fill: none;
	stroke: var(--border);
	stroke-width: 3;
}

.progress-circle__fill {
	fill: none;
	stroke: var(--accent);
	stroke-width: 3;
	stroke-linecap: round;
	stroke-dasharray: 157;
	stroke-dashoffset: 157;
	will-change: stroke-dashoffset;
}

.progress-circle__text {
	position: absolute;
	inset: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	font-family: 'JetBrains Mono', monospace;
	font-size: 0.7rem;
	color: var(--text-muted);
}

/* Top-right variant */
.progress-circle--top-right {
	bottom: auto;
	top: 2rem;
}

/* Top-left variant */
.progress-circle--top-left {
	bottom: auto;
	right: auto;
	top: 2rem;
	left: 2rem;
}

/* Bottom-left variant */
.progress-circle--bottom-left {
	right: auto;
	left: 2rem;
}

/* ============================================
   SCROLL PROGRESS - SIDE RAIL
   ============================================ */
.progress-rail {
	position: fixed;
	top: 0;
	width: 4px;
	height: 100vh;
	background: var(--border);
	z-index: 1000;
}

.progress-rail--left {
	left: 0;
}

.progress-rail--right {
	right: 0;
}

.progress-rail__fill {
	width: 100%;
	height: 100%;
	background: var(--accent);
	transform-origin: top;
	transform: scaleY(0);
	will-change: transform;
}

/* ============================================
   SCROLL PROGRESS - PERCENTAGE COUNTER
   ============================================ */
.progress-counter {
	position: fixed;
	bottom: 2rem;
	left: 2rem;
	font-family: 'JetBrains Mono', monospace;
	font-size: 1rem;
	color: var(--text-muted);
	z-index: 1000;
	display: flex;
	align-items: baseline;
	gap: 0.1em;
}

.progress-counter__value {
	font-size: 2rem;
	font-weight: 700;
	color: var(--accent);
	min-width: 2.5ch;
	text-align: right;
}

.progress-counter__symbol {
	font-size: 1rem;
	color: var(--text-muted);
}

/* Top-left variant */
.progress-counter--top-left {
	bottom: auto;
	top: 2rem;
}

/* Top-right variant */
.progress-counter--top-right {
	bottom: auto;
	left: auto;
	top: 2rem;
	right: 2rem;
}

/* Bottom-right variant */
.progress-counter--bottom-right {
	left: auto;
	right: 2rem;
}

/* ============================================
   INFO SECTION
   ============================================ */
.example--info .example-number {
	background: var(--accent-dim);
	padding: 0.5rem;
	border-radius: 8px;
	display: flex;
	align-items: center;
	justify-content: center;
}

.example--info .example-number svg {
	color: var(--accent);
}

.info-grid {
	display: grid;
	grid-template-columns: repeat(2, 1fr);
	gap: 1.25rem;
}

.info-card {
	background: var(--surface);
	border: 1px solid var(--border);
	border-radius: var(--radius);
	padding: 1.5rem;
}

.info-card h3 {
	font-size: 0.85rem;
	font-weight: 600;
	margin-bottom: 1rem;
	padding-bottom: 0.75rem;
	border-bottom: 1px solid var(--border);
}

.info-card--positive h3 {
	color: var(--accent);
}

.info-card--neutral h3 {
	color: var(--text-muted);
}

.info-card ul {
	list-style: none;
}

.info-card li {
	font-size: 0.85rem;
	color: var(--text-muted);
	padding: 0.35rem 0;
	padding-left: 1.25rem;
	position: relative;
}

.info-card li::before {
	content: '';
	position: absolute;
	left: 0;
	top: 0.75rem;
	width: 4px;
	height: 4px;
	border-radius: 50%;
	background: var(--border-hover);
}

.info-card--positive li::before {
	background: var(--accent);
}

/* ============================================
   RESPONSIVE
   ============================================ */
@media (max-width: 768px) {
	.container {
		padding: 2rem 1.25rem 6rem;
	}

	.example {
		margin-bottom: 4rem;
	}

	.demo-area {
		padding: 1.5rem;
	}

	.info-grid {
		grid-template-columns: 1fr;
	}

	.code-block pre {
		padding: 1rem;
	}

	.code-block code {
		font-size: 0.75rem;
	}

	/* Progress indicators - smaller on mobile */
	.progress-circle {
		width: 50px;
		height: 50px;
		bottom: 1rem;
		right: 1rem;
	}

	.progress-counter {
		bottom: 1rem;
		left: 1rem;
	}

	.progress-counter__value {
		font-size: 1.5rem;
	}
}

/* ============================================
   REDUCED MOTION
   ============================================ */
@media (prefers-reduced-motion: reduce) {
	*,
	*::before,
	*::after {
		animation-duration: 0.01ms !important;
		animation-delay: 0ms !important;
		transition-duration: 0.01ms !important;
	}

	/* Show progress at 100% for reduced motion */
	.progress-bar__fill {
		transform: scaleX(1);
	}

	.progress-circle__fill {
		stroke-dashoffset: 0;
	}

	.progress-rail__fill {
		transform: scaleY(1);
		height: 100%;
	}

	.progress-counter__value::after {
		content: '100';
	}
}

Four scroll progress visualization styles using GSAP ScrollTrigger.

Quick Start

1. Add to your HTML <head>:

<link rel="stylesheet" href="path/to/style.css">

2. Add before closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
<script src="path/to/script.js"></script>

3. Add the indicator HTML anywhere in your <body>:

<div class="progress-bar" data-progress-style="bar">
  <div class="progress-bar__fill"></div>
</div>

Styles

Style Description Best For
bar Horizontal bar at top/bottom Blog posts, documentation
circle SVG ring with optional percentage Landing pages, minimal designs
rail Vertical bar on left/right edge Editorial sites, vertical emphasis
counter Numeric percentage display Technical docs, data-driven UIs

Style Markup

Each style requires specific HTML. Add one of these anywhere in your <body>:

Bar

HTML:

<div class="progress-bar" data-progress-style="bar">
  <div class="progress-bar__fill"></div>
</div>

Variants: Add progress-bar--bottom for bottom position, progress-bar--thick for 5px height.

Circle

HTML:

<div class="progress-circle" data-progress-style="circle">
  <svg class="progress-circle__svg" viewBox="0 0 60 60">
    <circle class="progress-circle__bg" cx="30" cy="30" r="25"/>
    <circle class="progress-circle__fill" cx="30" cy="30" r="25"/>
  </svg>
  <span class="progress-circle__text">0%</span>
</div>

Variants: Add position classes:

  • progress-circle--top-right
  • progress-circle--top-left
  • progress-circle--bottom-left
  • Default is bottom-right

Rail

HTML:

<div class="progress-rail progress-rail--right" data-progress-style="rail">
  <div class="progress-rail__fill"></div>
</div>

Variants: progress-rail--left or progress-rail--right

Counter

HTML:

<div class="progress-counter" data-progress-style="counter">
  <span class="progress-counter__value">0</span>
  <span class="progress-counter__symbol">%</span>
</div>

Variants: Add position classes:

  • progress-counter--top-left
  • progress-counter--top-right
  • progress-counter--bottom-right
  • Default is bottom-left

Core Pattern

All styles use the same ScrollTrigger pattern. This is already included in script.js — shown here for reference:

JavaScript:

ScrollTrigger.create({
  trigger: document.documentElement,
  start: 'top top',
  end: 'bottom bottom',
  scrub: 0.5,
  onUpdate: (self) => {
    // self.progress = 0 to 1
    updateProgress(self.progress);
  }
});

Programmatic Use

The ScrollProgress class is exposed globally. Add this to your own JavaScript file or a <script> tag:

JavaScript:

const element = document.querySelector('.my-progress');
const progress = new ScrollProgress(element, {
  style: 'bar' // 'bar', 'circle', 'rail', 'counter'
});

// Cleanup
progress.destroy();

Customization

Add these to your own stylesheet or a <style> tag to override defaults.

Colors

CSS:

:root {
  --accent: #ff6b6b;        /* Progress fill color */
  --border: rgba(255, 255, 255, 0.1); /* Track color */
}

Bar Height

CSS:

.progress-bar {
  height: 5px; /* Default is 3px */
}

Circle Size

CSS:

.progress-circle {
  width: 80px;
  height: 80px;
}

Adjust stroke-dasharray when changing circle radius:

  • Formula: 2 * PI * radius
  • Default (r=25): 157

Rail Width

CSS:

.progress-rail {
  width: 6px; /* Default is 4px */
}

Accessibility

  • All indicators use aria-hidden="true" (decorative, not interactive)
  • Respects prefers-reduced-motion — shows static 100% state
  • No keyboard interaction needed (passive visualization)
  • Scroll position remains accessible via native browser UI

Browser Support

Modern browsers (ES6+). Not compatible with IE11.

Dependencies

  • GSAP 3.12+
  • ScrollTrigger plugin
  • Lenis (optional — for smooth scrolling; effect works without it)

Your Cart

Your cart is empty

Browse Effects