f02

Scroll Progress Indicator

Free

A precise GSAP reading-progress indicator in four shapes: a top bar, an SVG ring, a side rail, and a numeric percentage.

ScrollTrigger beginner
3 more details
scroll-progressprogress-barsvg-stroke
Scroll Progress Indicator - GSAP animation effect preview

This demo reads better at your own screen size than in the frame below:

About this effect

A reading-progress indicator that shows how far through a page a visitor has read. GSAP ScrollTrigger keeps four independent display styles in sync with scroll position: a linear bar along the top edge, an SVG ring, a vertical side rail, and a numeric percentage. Drop one into an article, or use several at once.

What's included

7 items
  • Four independent styles: linear bar, circular ring, vertical rail, and percentage counter
  • Exact 0-100 progress mapping with a guaranteed 100% endpoint
  • Fully reversible updates with no delayed scrub at either boundary
  • Position modifier classes for each style (top or bottom bar, any corner for the ring and counter, either side for the rail)
  • Programmatic ScrollProgress class with configurable trigger, start, and end, so the range can be one article rather than the whole document
  • role=progressbar with a live aria-valuenow on every style
  • Reduced-motion branch retains readable, direct progress feedback

Perfect for

5 use cases
  • Blog posts and long-form articles with an unobtrusive top progress bar
  • Documentation pages that need a sense of remaining length
  • Case studies with numeric scroll-depth feedback
  • Landing pages using a compact circular progress ring in a corner
  • Editorial layouts with a full-height side rail

How it works

1 section

Each element with a data-progress-style attribute creates an independent ScrollTrigger spanning the document from top/top to bottom/bottom. Its normalized 0-1 progress directly drives scaleX for the bar, SVG stroke-dashoffset for the ring, scaleY for the rail, or an exact integer percentage for the counter, and writes the same value to aria-valuenow. The final fraction is clamped so the indicator lands on an exact 100, and every trigger is killed by the public cleanup routine.

Plugins ScrollTrigger
Difficulty Beginner
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect's demo, 15 September 2026. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>Scroll Progress Indicator Demo | GSAP Vault</title>
	<link rel="stylesheet" href="assets/style.css">
</head>
<body data-mode="all">
	<!-- The four indicators. Each one is independent: keep the ones you want,
	     delete the rest. -->
	<div class="progress-bar" data-progress-style="bar" data-progress-position="top" role="progressbar" aria-label="Reading progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
		<div class="progress-bar__fill"></div>
	</div>

	<div class="progress-rail progress-rail--left" data-progress-style="rail" data-progress-position="left" role="progressbar" aria-label="Reading progress rail" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
		<div class="progress-rail__fill"></div>
	</div>

	<div class="progress-circle" data-progress-style="circle" data-progress-position="bottom-right" role="progressbar" aria-label="Circular reading progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
		<svg class="progress-circle__svg" viewBox="0 0 120 120" aria-hidden="true">
			<circle class="progress-circle__bg" cx="60" cy="60" r="52"/>
			<circle class="progress-circle__fill" cx="60" cy="60" r="52"/>
		</svg>
		<span class="progress-circle__text">0%</span>
	</div>

	<div class="progress-counter" data-progress-style="counter" data-progress-position="bottom-left" role="progressbar" aria-label="Numeric reading progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
		<span class="progress-counter__value">000</span>
		<span class="progress-counter__symbol">%</span>
	</div>

	<header class="showcase-toolbar">
		<p class="showcase-prompt"><span aria-hidden="true">&darr;</span> Scroll to read</p>
		<div class="mode-switcher" role="group" aria-label="Progress indicator shown">
			<button type="button" data-mode-button="all" aria-pressed="true">All</button>
			<button type="button" data-mode-button="bar" aria-pressed="false">Bar</button>
			<button type="button" data-mode-button="circle" aria-pressed="false">Ring</button>
			<button type="button" data-mode-button="rail" aria-pressed="false">Rail</button>
			<button type="button" data-mode-button="counter" aria-pressed="false">%</button>
		</div>
	</header>

	<main class="page">
		<article class="article" data-thumbnail-target>
			<p class="eyebrow">Typography &middot; 6 min read</p>
			<h1>Writing pages that people finish</h1>
			<p class="standfirst">Most long articles are abandoned in the first screen and a half. Very little of that is the writing. Most of it is the page.</p>

			<p>A reader arriving on a long page makes one decision before they make any other: is this worth the next few minutes? They answer it without reading a word, from the shape of the thing. Line length, the size of the gaps, how far the scrollbar suggests they have to go. Design the page badly and the best paragraph you have ever written never gets looked at.</p>

			<h2>Give the reader a sense of distance</h2>
			<p>People are happy to commit to a long walk if they can see where it ends. The same is true of an article. A reading-progress indicator is the cheapest way to answer the question, and it keeps answering it as they go: a quarter of the way, halfway, nearly there.</p>
			<p>It works because it is honest. The bar moves with the scroll position and nothing else, so it never promises a short read and then delivers a long one. If your page has a comment thread or a footer full of related links, end the measured range at the end of the article rather than the end of the document, or the last paragraph will sit at seventy per cent and feel like a lie.</p>

			<h2>Set the measure before the typeface</h2>
			<p>Line length does more work than the font choice. Somewhere between sixty and seventy-five characters, the eye finds the start of the next line without effort. Push past ninety and readers start losing their place; drop below forty-five and they get seasick from the return sweep.</p>
			<p>Once the measure is right, the leading follows from it. Longer lines need more space between them. On a phone, where the measure is short by force, tight leading reads fine and saves the reader a great deal of scrolling.</p>

			<h2>Break the wall</h2>
			<p>A subheading every three or four paragraphs is not a stylistic flourish. It is a resting point, and it gives the skimmer a way to decide whether the next section is for them. Readers who skim and then come back are worth more than readers who bounce.</p>
			<p>The same goes for paragraph length. Four lines is comfortable, eight is a wall. When a paragraph runs long, it is almost always because it is doing two jobs; splitting it usually improves the argument as well as the page.</p>

			<h2>Let the page be quiet</h2>
			<p>Everything that moves competes with the sentence being read. This is the argument against most of what gets bolted onto article pages: the sticky share bar that eats a fifth of a phone screen, the newsletter modal at thirty per cent, the related-posts carousel that autoplays in the margin.</p>
			<p>A progress indicator survives that test because it is peripheral. Three pixels along the top edge, or a small ring in a corner, is legible at a glance and invisible the rest of the time. If yours needs a label to explain it, it is too loud.</p>

			<h2>Respect the reader's settings</h2>
			<p>Some readers have asked their operating system for less motion, and a progress indicator is a good example of a thing that should still work for them. The information is useful; only the easing is decorative. Keep the number accurate and drop the smoothing.</p>
			<p>The same applies to screen readers. A progress element with a role, a label and a value that updates is a small amount of markup and it turns an ornament into something that actually reports position.</p>

			<h2>Finish at the end</h2>
			<p>An indicator that stops at ninety-seven per cent is worse than none at all, because the reader notices and stops trusting it. Clamp the last fraction so the page lands on an exact hundred at the bottom, and let the final paragraph have the room to be the last thing on the screen.</p>
			<p>That is the whole trick, really. Tell the reader how far they have come, tell them the truth, and then get out of the way.</p>
		</article>
	</main>

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

/* Runs immediately when the DOM is ready, including when this file is deferred. */
(function onReady(init) {
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', init, { once: true });
	} else {
		init();
	}
})(function initScrollProgress() {
	document.documentElement.classList.add('has-js');

	const instances = [];
	const handlers = new Map();

	/* CORE: each style remains independently usable through data attributes or JS. */
	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.triggerElement = options.trigger || document.documentElement;
			this.start = options.start || 'top top';
			this.end = options.end || 'bottom bottom';
			this.trigger = null;
			this.update = null;
			this.init();
		}

		init() {
			const element = this.element;
			let render;

			if (this.style === 'bar') {
				const fill = element.querySelector('.progress-bar__fill');
				if (fill) render = function(progress) {
					gsap.set(fill, { scaleX: progress });
				};
			}

			if (this.style === 'circle' || this.style === 'ring') {
				const path = element.querySelector('.progress-circle__fill');
				const text = element.querySelector('.progress-circle__text');
				if (path) {
					const circumference = path.getTotalLength ? path.getTotalLength() : 157;
					gsap.set(path, { strokeDasharray: circumference });
					render = function(progress) {
						gsap.set(path, { strokeDashoffset: circumference * (1 - progress) });
						if (text && text.isConnected) text.textContent = Math.round(progress * 100) + '%';
					};
				}
			}

			if (this.style === 'rail') {
				const fill = element.querySelector('.progress-rail__fill');
				if (fill) render = function(progress) {
					gsap.set(fill, { scaleY: progress });
				};
			}

			if (this.style === 'counter' || this.style === 'percentage') {
				const value = element.querySelector('.progress-counter__value');
				if (value) render = function(progress) {
					if (value.isConnected) value.textContent = String(Math.round(progress * 100)).padStart(3, '0');
				};
			}

			if (!render) return;

			this.update = function(rawProgress) {
				/* Clamp the final fraction so the indicator lands on an exact 100. */
				const progress = rawProgress >= 0.9995 ? 1 : gsap.utils.clamp(0, 1, rawProgress);
				render(progress);
				element.setAttribute('aria-valuenow', Math.round(progress * 100));
			};

			const update = this.update;
			this.trigger = ScrollTrigger.create({
				trigger: this.triggerElement,
				start: this.start,
				end: this.end,
				onUpdate: function(self) { update(self.progress); },
				onRefresh: function(self) { update(self.progress); }
			});
			this.update(this.trigger.progress);
		}

		destroy() {
			if (this.trigger) this.trigger.kill();
			this.trigger = null;
		}
	}

	function mountIndicators() {
		document.querySelectorAll('[data-progress-style]').forEach(function(element) {
			instances.push(new ScrollProgress(element));
		});

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

	function mountModeSwitcher() {
		const buttons = document.querySelectorAll('[data-mode-button]');
		buttons.forEach(function(button) {
			const handleClick = function() {
				const mode = button.dataset.modeButton;
				document.body.dataset.mode = mode;
				buttons.forEach(function(item) {
					item.setAttribute('aria-pressed', String(item === button));
				});
			};
			button.addEventListener('click', handleClick);
			handlers.set(button, handleClick);
		});
	}

	const ctx = gsap.context(function() {
		const mm = gsap.matchMedia();
		mm.add('(prefers-reduced-motion: no-preference)', mountIndicators);
		/* Progress remains useful in reduced motion; updates are direct, with no scrub. */
		mm.add('(prefers-reduced-motion: reduce)', mountIndicators);
		mountModeSwitcher();
	});

	function destroy() {
		handlers.forEach(function(handler, element) {
			element.removeEventListener('click', handler);
		});
		handlers.clear();
		ctx.revert();
	}

	window.ScrollProgress = ScrollProgress;
	window.gsapContext = ctx;
	window.destroyScrollProgress = destroy;
	window.addEventListener('beforeunload', destroy, { once: true });
});
gsap.registerPlugin(ScrollTrigger),function(t){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t,{once:!0}):t()}(function(){document.documentElement.classList.add("has-js");const t=[],e=new Map;class n{constructor(t,e={}){this.element=t,this.style=e.style||t.dataset.progressStyle||"bar",this.position=e.position||t.dataset.progressPosition||null,this.triggerElement=e.trigger||document.documentElement,this.start=e.start||"top top",this.end=e.end||"bottom bottom",this.trigger=null,this.update=null,this.init()}init(){const t=this.element;let e;if("bar"===this.style){const n=t.querySelector(".progress-bar__fill");n&&(e=function(t){gsap.set(n,{scaleX:t})})}if("circle"===this.style||"ring"===this.style){const n=t.querySelector(".progress-circle__fill"),r=t.querySelector(".progress-circle__text");if(n){const t=n.getTotalLength?n.getTotalLength():157;gsap.set(n,{strokeDasharray:t}),e=function(e){gsap.set(n,{strokeDashoffset:t*(1-e)}),r&&r.isConnected&&(r.textContent=Math.round(100*e)+"%")}}}if("rail"===this.style){const n=t.querySelector(".progress-rail__fill");n&&(e=function(t){gsap.set(n,{scaleY:t})})}if("counter"===this.style||"percentage"===this.style){const n=t.querySelector(".progress-counter__value");n&&(e=function(t){n.isConnected&&(n.textContent=String(Math.round(100*t)).padStart(3,"0"))})}if(!e)return;this.update=function(n){const r=n>=.9995?1:gsap.utils.clamp(0,1,n);e(r),t.setAttribute("aria-valuenow",Math.round(100*r))};const n=this.update;this.trigger=ScrollTrigger.create({trigger:this.triggerElement,start:this.start,end:this.end,onUpdate:function(t){n(t.progress)},onRefresh:function(t){n(t.progress)}}),this.update(this.trigger.progress)}destroy(){this.trigger&&this.trigger.kill(),this.trigger=null}}function r(){return document.querySelectorAll("[data-progress-style]").forEach(function(e){t.push(new n(e))}),function(){t.forEach(function(t){t.destroy()}),t.length=0}}const s=gsap.context(function(){const t=gsap.matchMedia();t.add("(prefers-reduced-motion: no-preference)",r),t.add("(prefers-reduced-motion: reduce)",r),function(){const t=document.querySelectorAll("[data-mode-button]");t.forEach(function(n){const r=function(){const e=n.dataset.modeButton;document.body.dataset.mode=e,t.forEach(function(t){t.setAttribute("aria-pressed",String(t===n))})};n.addEventListener("click",r),e.set(n,r)})}()});function o(){e.forEach(function(t,e){e.removeEventListener("click",t)}),e.clear(),s.revert()}window.ScrollProgress=n,window.gsapContext=s,window.destroyScrollProgress=o,window.addEventListener("beforeunload",o,{once:!0})});
* {
	box-sizing: border-box;
	margin: 0;
	padding: 0;
}

:root {
	/* A reading page, because that is where a reading-progress indicator
	   lives. One accent, used only by the indicators. */
	--page: #f5f4f0;
	--ink: #17171a;
	--muted: rgba(23, 23, 26, 0.62);
	--line: rgba(23, 23, 26, 0.14);
	--accent: #2f5f6b;
	--track: rgba(23, 23, 26, 0.1);
	color-scheme: light;
}

html {
	scroll-behavior: auto;
	scrollbar-color: rgba(23, 23, 26, 0.28) var(--page);
	scrollbar-width: thin;
}

body {
	min-width: 320px;
	background: var(--page);
	color: var(--ink);
	font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
	-webkit-font-smoothing: antialiased;
	overflow-x: clip;
}

::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--page); }
::-webkit-scrollbar-thumb { background: rgba(23, 23, 26, 0.28); }

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

button { font: inherit; }

/* Page furniture: one prompt line and the switcher that shows the four styles. */
.showcase-toolbar {
	position: sticky;
	top: 0;
	z-index: 40;
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 1rem;
	height: 48px;
	padding: 0 clamp(1rem, 4vw, 2.25rem);
	border-bottom: 1px solid var(--line);
	background: rgba(245, 244, 240, 0.94);
	backdrop-filter: blur(14px);
}

.showcase-prompt {
	display: flex;
	align-items: center;
	gap: 0.5rem;
	color: var(--muted);
	font-size: 0.8rem;
	white-space: nowrap;
}

.showcase-prompt span { color: var(--accent); }

.mode-switcher {
	display: flex;
	gap: 0.15rem;
	padding: 0.2rem;
	border: 1px solid var(--line);
	border-radius: 6px;
}

.mode-switcher button {
	min-width: 2.6rem;
	padding: 0.3rem 0.6rem;
	border: 0;
	border-radius: 4px;
	background: transparent;
	color: var(--muted);
	font-size: 0.78rem;
	cursor: pointer;
}

.mode-switcher button:hover { color: var(--ink); }
.mode-switcher button:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; }

.mode-switcher button[aria-pressed='true'] {
	background: var(--accent);
	color: #fff;
}

/* Plausible page content: the article the indicators are measuring. */
.page {
	padding: clamp(2.5rem, 7vw, 5rem) clamp(1.15rem, 5vw, 2.5rem) clamp(5rem, 12vh, 9rem);
}

.article {
	width: min(100%, 40rem);
	margin: 0 auto;
}

.eyebrow {
	color: var(--muted);
	font-size: 0.82rem;
}

.article h1 {
	margin: 0.75rem 0 1.1rem;
	font-size: clamp(2.1rem, 6vw, 3.4rem);
	font-weight: 650;
	line-height: 1.04;
	letter-spacing: -0.035em;
	text-wrap: balance;
}

.standfirst {
	margin-bottom: 2.4rem;
	padding-bottom: 2.4rem;
	border-bottom: 1px solid var(--line);
	color: var(--muted);
	font-size: clamp(1.05rem, 2.2vw, 1.3rem);
	line-height: 1.55;
}

.article h2 {
	margin: 2.9rem 0 1rem;
	font-size: clamp(1.35rem, 3vw, 1.75rem);
	font-weight: 620;
	line-height: 1.2;
	letter-spacing: -0.02em;
}

.article p + p { margin-top: 1.15rem; }

.article > p {
	font-size: clamp(1rem, 1.4vw, 1.09rem);
	line-height: 1.68;
}

/* Buyer API: top/bottom bar */
.progress-bar {
	position: fixed;
	top: 0;
	left: 0;
	z-index: 100;
	width: 100%;
	height: 3px;
	background: var(--track);
	transition: opacity 160ms linear;
}

.progress-bar--bottom { top: auto; bottom: 0; }
.progress-bar--thick { height: 5px; }
.progress-bar__fill {
	width: 100%;
	height: 100%;
	background: var(--accent);
	transform: scaleX(0);
	transform-origin: left center;
	will-change: transform;
}

/* Buyer API: circular ring */
.progress-circle {
	position: fixed;
	right: 1.5rem;
	bottom: 1.5rem;
	z-index: 30;
	width: 74px;
	aspect-ratio: 1;
	border-radius: 50%;
	background: var(--page);
	transition: opacity 160ms linear;
}

.progress-circle--top-right { top: 1.5rem; bottom: auto; }
.progress-circle--top-left { top: 1.5rem; right: auto; bottom: auto; left: 1.5rem; }
.progress-circle--bottom-left { right: auto; left: 1.5rem; }
.progress-circle__svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.progress-circle__bg,
.progress-circle__fill { fill: none; }
.progress-circle__bg { stroke: var(--track); stroke-width: 3; }
.progress-circle__fill {
	stroke: var(--accent);
	stroke-width: 3;
	stroke-linecap: butt;
	stroke-dashoffset: 327;
	will-change: stroke-dashoffset;
}
.progress-circle__text {
	position: absolute;
	inset: 0;
	display: grid;
	place-items: center;
	/* Mono keeps the digits from shuffling sideways as the number changes. */
	font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
	font-size: 0.78rem;
	color: var(--ink);
}

/* Buyer API: side rail */
.progress-rail {
	position: fixed;
	top: 0;
	left: 0;
	/* Above the demo's sticky toolbar, so the rail reads full height. */
	z-index: 50;
	width: 4px;
	height: 100vh;
	height: 100svh;
	background: var(--track);
	transition: opacity 160ms linear;
}
.progress-rail--left { right: auto; left: 0; }
.progress-rail--right { right: 0; left: auto; }
.progress-rail__fill {
	width: 100%;
	height: 100%;
	background: var(--accent);
	transform: scaleY(0);
	transform-origin: center top;
	will-change: transform;
}

/* Buyer API: percentage counter */
.progress-counter {
	position: fixed;
	bottom: 1.5rem;
	left: 1.5rem;
	z-index: 30;
	display: flex;
	align-items: baseline;
	gap: 0.08em;
	/* A paper chip so the number stays legible over the text it floats above. */
	padding: 0.35rem 0.6rem;
	border: 1px solid var(--line);
	border-radius: 6px;
	background: var(--page);
	font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
	transition: opacity 160ms linear;
}
.progress-counter--top-left { top: 1.5rem; bottom: auto; }
.progress-counter--top-right { top: 1.5rem; right: 1.5rem; bottom: auto; left: auto; }
.progress-counter--bottom-right { right: 1.5rem; left: auto; }
.progress-counter__symbol { color: var(--muted); font-size: 1rem; line-height: 1; }
.progress-counter__value {
	min-width: 3ch;
	color: var(--accent);
	font-size: 1.6rem;
	font-weight: 600;
	line-height: 1;
	text-align: right;
}

/* The switcher fades the other three back rather than removing them, so their
   placement on the page stays visible while one is being looked at. */
body:not([data-mode='all']) .progress-bar,
body:not([data-mode='all']) .progress-rail,
body:not([data-mode='all']) .progress-circle,
body:not([data-mode='all']) .progress-counter { opacity: 0.12; }
body[data-mode='bar'] .progress-bar,
body[data-mode='circle'] .progress-circle,
body[data-mode='rail'] .progress-rail,
body[data-mode='counter'] .progress-counter { opacity: 1; }

@media (max-width: 760px) {
	.showcase-toolbar { height: 44px; gap: 0.5rem; padding: 0 0.75rem; }
	.showcase-prompt { font-size: 0.72rem; }
	.mode-switcher button { min-width: 2rem; padding-inline: 0.4rem; font-size: 0.72rem; }
	.progress-circle { right: 1rem; bottom: 1rem; width: 62px; }
	.progress-counter { bottom: 1rem; left: 1rem; }
	.progress-counter__value { font-size: 1.3rem; }
	/* The article ends above the fixed corner indicators. */
	.page { padding-bottom: 7rem; }
}

@media (max-width: 400px) {
	.mode-switcher { gap: 0; }
	.mode-switcher button { min-width: 1.7rem; padding-inline: 0.3rem; }
}

@media (prefers-reduced-motion: reduce) {
	*, *::before, *::after {
		animation-duration: 0.01ms !important;
		animation-iteration-count: 1 !important;
		transition-duration: 0.01ms !important;
		scroll-behavior: auto !important;
	}
}

A precise, reversible reading-progress indicator built with GSAP ScrollTrigger. Use the bar, ring, rail, or percentage on its own, or run several at once.

Quick Start

1. Add to your HTML <head>:

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

2. Add one indicator inside your <body>:

<div class="progress-bar" data-progress-style="bar"
  role="progressbar" aria-label="Reading progress"
  aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
  <div class="progress-bar__fill"></div>
</div>

3. Add before the closing </body> tag:

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

The default range is the full document, from top top to bottom bottom. Updates are direct, reversible, and settle on an exact 100 at the endpoint.

Options

Data attributes

Attribute Values Default Description
data-progress-style bar, circle, ring, rail, counter, percentage bar Selects the rendering mode. ring and percentage are aliases.
data-progress-position Any project-specific value Preserved on the instance for custom positioning logic. CSS position classes are provided below.

JavaScript options

Option Type Default Description
style string Element data attribute or bar Rendering mode.
position string Element data attribute or null Optional position metadata.
trigger Element document.documentElement Element whose scroll range is tracked.
start string top top ScrollTrigger start expression.
end string bottom bottom ScrollTrigger end expression.

Style Markup

Bar

Add inside your <body>:

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

Add progress-bar--bottom for the bottom edge or progress-bar--thick for a 5px bar.

Ring

Add inside your <body>:

<div class="progress-circle" data-progress-style="circle">
  <svg class="progress-circle__svg" viewBox="0 0 60 60" aria-hidden="true">
    <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>

The script reads the path length, so changing the radius does not require a hard-coded dash array. Position classes are progress-circle--top-right, progress-circle--top-left, and progress-circle--bottom-left; bottom-right is the default.

Rail

Add inside your <body>:

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

Use progress-rail--left or progress-rail--right.

Percentage

Add inside your <body>:

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

Position classes are progress-counter--top-left, progress-counter--top-right, and progress-counter--bottom-right; bottom-left is the default.

Programmatic Example

The global ScrollProgress class supports custom scroll ranges.

Add to your JavaScript after script.js:

const article = document.querySelector('.article');
const indicator = document.querySelector('.article-progress');

const progress = new ScrollProgress(indicator, {
  style: 'bar',
  trigger: article,
  start: 'top top',
  end: 'bottom bottom'
});

// Remove this instance and its ScrollTrigger when no longer needed.
progress.destroy();

For single-page app teardown, call the included global cleanup:

window.destroyScrollProgress();

Customization

Add to your own stylesheet after style.css:

:root {
  --accent: #2f5bd7;   /* the fill colour of every style */
  --track: rgba(23, 23, 26, 0.1);   /* the unfilled part of the bar, ring and rail */
}

.progress-bar { height: 5px; }
.progress-circle { width: 88px; }
.progress-rail { width: 4px; }

Accessibility

  • Add role="progressbar", an accessible label, aria-valuemin="0", and aria-valuemax="100"; the script maintains aria-valuenow.
  • Progress remains live under prefers-reduced-motion because it is useful information, but updates have no scrub, easing, or decorative transition.
  • All controls in the demo are native buttons and keyboard operable.
  • With JavaScript unavailable, the page content and the empty indicators remain visible; nothing is hidden waiting for script.

Cleanup

Every instance owns one ScrollTrigger and exposes destroy(). The demo wraps initialization in gsap.context(), uses gsap.matchMedia() for both motion preferences, removes its mode-switch listeners, and destroys all instances on teardown.

Dependencies

  • GSAP 3.15.0+
  • ScrollTrigger 3.15.0+
  • No smooth-scroll library required

Your cart

Your cart is empty

The Vault £99

The Vault library, plus future additions to the library.