# Scroll Progress Indicator

> A precise GSAP reading-progress instrument with bar, ring, side rail, percentage, and active chapter feedback.

Canonical: https://gsapvault.com/effects/scroll-progress
Live demo: https://gsapvault.com/demos/scroll-progress/index.html

| Property | Value |
|----------|-------|
| Type | effect |
| Tier | free |
| Price | Free |
| Difficulty | beginner |
| Plugins | ScrollTrigger |
| Techniques | scroll-progress, progress-bar, svg-stroke, scroll-linked-color |
| Uses Lenis | No |

## Overview

A precise editorial scroll progress effect that turns reading position into a compact navigation instrument. GSAP ScrollTrigger keeps a linear bar, SVG ring, side rail, numeric percentage, active chapter marker, and restrained orange-to-lime colour response in sync.

## Features

- Four preserved buyer APIs: 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
- Active chapter marker synchronized to the same page progress
- Scroll-linked orange-to-lime instrument colour
- Compact semantic mode switcher over one focused showcase
- Programmatic ScrollProgress class with configurable trigger, start, and end
- Reduced-motion branch retains readable, direct progress feedback

## Use Cases

- Editorial field notes and long-form stories with precise reading feedback
- Blog posts with an unobtrusive top progress scale
- Documentation pages with route-style chapter orientation
- Case studies with numeric scroll-depth feedback
- Landing pages using a compact circular progress instrument

## Vibe-Code Ready Setup

This effect includes `START-HERE-AI.md`, a product-specific copy-paste setup prompt for Cursor, Claude Code, ChatGPT, GitHub Copilot, Windsurf, and other coding assistants. It tells the assistant to inspect the existing stack, integrate the supplied files, preserve the design, scope selectors, retain accessibility and responsive behaviour, add framework-appropriate GSAP cleanup, and report what it tested.

[How AI-assisted setup works](https://gsapvault.com/vibe-coding)

## How It Works

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. A separate showcase trigger updates the active chapter and interpolates the instrument accent while every trigger is killed by the public cleanup routine.

## Documentation

A precise, reversible reading-progress system built with GSAP ScrollTrigger. Use the bar, ring, rail, or percentage independently, or combine them into one navigation instrument.

## Quick Start

**1. Add to your HTML `<head>`:**

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

**2. Add one indicator inside your `<body>`:**

```html
<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:**

```html
<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>
```

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>`:**

```html
<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>`:**

```html
<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>`:**

```html
<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>`:**

```html
<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`:**

```javascript
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:

```javascript
window.destroyScrollProgress();
```

## Customization

**Add to your own stylesheet after `style.css`:**

```css
:root {
  --accent: #c8ff00;
  --orange: #ff6b1a;
}

.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, article content and the initial instrument remain visible and readable.

## Cleanup

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

## Dependencies

- GSAP 3.14.2+
- ScrollTrigger 3.14.2+
- No smooth-scroll library required

## Source Code

This effect is free. The complete source is included below.

### index.html

```html
<!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">
	<link rel="preconnect" href="https://fonts.googleapis.com">
	<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
	<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet">
</head>
<body data-mode="all">
	<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>

	<header class="showcase-toolbar">
		<p class="showcase-prompt"><span aria-hidden="true">↓</span> Scroll to read</p>
		<div class="mode-switcher" role="group" aria-label="Progress display mode">
			<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="field-note" data-thumbnail-target>
		<div class="field-note__layout">
			<aside class="instrument" aria-label="Route progress instrument">
				<div class="instrument__topline">
					<span>FIELD / 07</span>
					<span>ALT. 2,140M</span>
				</div>

				<div class="instrument__dial progress-circle" data-progress-style="circle" 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"/>
						<path class="progress-circle__crosshair" d="M60 0v16M60 104v16M0 60h16M104 60h16"/>
					</svg>
					<span class="progress-circle__text">0%</span>
				</div>

				<div class="route">
					<div class="progress-rail" data-progress-style="rail" role="progressbar" aria-label="Route progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
						<div class="progress-rail__fill"></div>
					</div>
					<ol class="route__stops" aria-label="Manifesto chapters">
						<li data-route-stop class="is-active" aria-current="step"><span>01</span> Depart</li>
						<li data-route-stop><span>02</span> Observe</li>
						<li data-route-stop><span>03</span> Traverse</li>
						<li data-route-stop><span>04</span> Return</li>
					</ol>
				</div>

				<div class="instrument__readout">
					<p class="instrument__chapter"><span>CHAPTER</span><strong data-current-chapter>DEPART</strong></p>
					<div class="progress-counter" data-progress-style="counter" role="progressbar" aria-label="Numeric reading progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
						<span class="progress-counter__prefix">N /</span>
						<span class="progress-counter__value">000</span>
						<span class="progress-counter__symbol">%</span>
					</div>
				</div>
			</aside>

			<article class="manifesto" aria-labelledby="manifesto-title">
				<section class="manifesto__chapter manifesto__chapter--lead" data-chapter="DEPART">
					<p class="eyebrow">Expedition note / north face</p>
					<h1 id="manifesto-title">Keep a line<br>through the noise.</h1>
					<p class="manifesto__standfirst">A short instruction for moving with intent when the terrain refuses certainty.</p>
				</section>

				<section class="manifesto__chapter" data-chapter="OBSERVE">
					<p class="chapter-index">01 — Observe</p>
					<h2>Read the weather before the map.</h2>
					<p>Plans are clean. Ground is not. Note the wind, the loose stone and the quiet change in light. Precision begins by looking again.</p>
				</section>

				<section class="manifesto__chapter" data-chapter="TRAVERSE">
					<p class="chapter-index">02 — Traverse</p>
					<h2>Choose the useful line.</h2>
					<p>The shortest route is rarely the truest. Move across the slope, keep three points steady, and let each deliberate step redraw the way forward.</p>
				</section>

				<section class="manifesto__chapter manifesto__chapter--final" data-chapter="RETURN">
					<p class="chapter-index">03 — Return</p>
					<h2>Finish at the mark.</h2>
					<p>Carry back only what changed your judgement. A route is complete when the final coordinate is exact—and reversible all the way home.</p>
					<span class="end-mark">END / 100</span>
				</section>
			</article>
		</div>
	</main>

	<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>
```

### assets/style.css

```css
* {
	box-sizing: border-box;
	margin: 0;
	padding: 0;
}

:root {
	--black: #080908;
	--surface: #10120f;
	--paper: #dedfd5;
	--ink: #171914;
	--muted: #8d9185;
	--border: rgba(222, 223, 213, 0.18);
	--line: var(--border);
	--accent: #c8ff00;
	--orange: #ff6b1a;
	color-scheme: dark;
}

html {
	scroll-behavior: auto;
	scrollbar-color: var(--accent) var(--black);
	scrollbar-width: thin;
}

body {
	min-width: 320px;
	background: var(--black);
	color: var(--paper);
	font-family: 'Space Grotesk', system-ui, sans-serif;
	-webkit-font-smoothing: antialiased;
	overflow-x: clip;
}

::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--black); }
::-webkit-scrollbar-thumb { background: var(--accent); }

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

button { font: inherit; }

/* Compact stage controls */
.showcase-toolbar {
	position: sticky;
	top: 0;
	z-index: 40;
	height: 48px;
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 1rem;
	padding: 0 2.25rem;
	border-bottom: 1px solid var(--line);
	background: rgba(8, 9, 8, 0.94);
	backdrop-filter: blur(14px);
}

.showcase-prompt {
	display: flex;
	gap: 0.65rem;
	align-items: center;
	font-family: 'JetBrains Mono', monospace;
	font-size: 0.65rem;
	letter-spacing: 0.12em;
	text-transform: uppercase;
	white-space: nowrap;
}

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

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

.mode-switcher button {
	min-width: 2.8rem;
	border: 0;
	padding: 0.3rem 0.58rem;
	background: transparent;
	color: var(--muted);
	font-family: 'JetBrains Mono', monospace;
	font-size: 0.62rem;
	letter-spacing: 0.08em;
	text-transform: uppercase;
	cursor: pointer;
}

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

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

/* Field note composition */
.field-note {
	--route-color: var(--orange);
	position: relative;
	background:
		radial-gradient(circle at 12% 26%, color-mix(in srgb, var(--route-color) 10%, transparent), transparent 30rem),
		var(--black);
	transition: background-color 180ms linear;
}

.field-note__layout {
	width: min(1160px, 100%);
	margin: 0 auto;
	display: grid;
	grid-template-columns: minmax(360px, 0.9fr) minmax(420px, 1.1fr);
	gap: clamp(3rem, 8vw, 8rem);
	padding: 0 clamp(2rem, 5vw, 4rem);
}

.instrument {
	position: sticky;
	top: 48px;
	height: calc(100vh - 48px);
	height: calc(100svh - 48px);
	align-self: start;
	display: grid;
	grid-template-columns: 1fr 0.68fr;
	grid-template-rows: auto 1fr auto;
	gap: 1.25rem 2rem;
	padding: clamp(1.5rem, 4vh, 3rem) 0;
	border-right: 1px solid var(--line);
}

.instrument::before,
.instrument::after {
	content: '';
	position: absolute;
	right: -4px;
	width: 7px;
	height: 1px;
	background: var(--route-color);
}

.instrument::before { top: 12%; }
.instrument::after { bottom: 12%; }

.instrument__topline {
	grid-column: 1 / -1;
	display: flex;
	justify-content: space-between;
	padding-right: 2rem;
	color: var(--muted);
	font-family: 'JetBrains Mono', monospace;
	font-size: 0.61rem;
	letter-spacing: 0.14em;
}

.instrument__dial {
	align-self: center;
	justify-self: start;
}

.route {
	position: relative;
	align-self: stretch;
	min-height: 230px;
}

.route__stops {
	height: 100%;
	list-style: none;
	display: flex;
	flex-direction: column;
	justify-content: space-between;
	padding: 0.2rem 1.35rem;
	font-family: 'JetBrains Mono', monospace;
	font-size: 0.58rem;
	letter-spacing: 0.08em;
	text-transform: uppercase;
	color: #5f635a;
}

.route__stops li {
	position: relative;
	transition: color 160ms linear, transform 160ms linear;
}

.route__stops li::before {
	content: '';
	position: absolute;
	left: -1.55rem;
	top: 50%;
	width: 7px;
	height: 7px;
	border: 1px solid #55594f;
	background: var(--black);
	transform: translateY(-50%);
}

.route__stops li.is-active {
	color: var(--route-color);
	transform: translateX(4px);
}

.route__stops li.is-active::before {
	border-color: var(--route-color);
	background: var(--route-color);
	box-shadow: 0 0 14px color-mix(in srgb, var(--route-color) 50%, transparent);
}

.route__stops span { margin-right: 0.45rem; }

.instrument__readout {
	grid-column: 1 / -1;
	display:flex;
	align-items: end;
	justify-content: space-between;
	gap: 1rem;
	padding-right: 2rem;
}

.instrument__chapter span {
	display: block;
	margin-bottom: 0.35rem;
	color: var(--muted);
	font-family: 'JetBrains Mono', monospace;
	font-size: 0.58rem;
	letter-spacing: 0.15em;
}

.instrument__chapter strong {
	font-family: 'Syne', sans-serif;
	font-size: clamp(1.3rem, 3vw, 2.4rem);
	line-height: 1;
	color: var(--route-color);
}

/* Editorial content */
.manifesto { width: min(100%, 560px); }

.manifesto__chapter {
	min-height: 68vh;
	min-height: 68svh;
	display: flex;
	flex-direction: column;
	justify-content: center;
	padding: clamp(3rem, 8vh, 6rem) 0;
	border-bottom: 1px solid var(--line);
}

.manifesto__chapter--lead {
	min-height: calc(100vh - 48px);
	min-height: calc(100svh - 48px);
}

.manifesto__chapter--final {
	min-height: calc(100vh - 48px);
	min-height: calc(100svh - 48px);
	border-bottom: 0;
}

.eyebrow,
.chapter-index,
.end-mark {
	font-family: 'JetBrains Mono', monospace;
	font-size: 0.66rem;
	line-height: 1.4;
	letter-spacing: 0.15em;
	text-transform: uppercase;
	color: var(--route-color);
}

.manifesto h1,
.manifesto h2 {
	font-family: 'Syne', sans-serif;
	letter-spacing: -0.045em;
}

.manifesto h1 {
	max-width: 10ch;
	margin: 1.2rem 0 1.5rem;
	font-size: clamp(3rem, 6.5vw, 6.2rem);
	font-weight: 700;
	line-height: 0.88;
}

.manifesto h2 {
	max-width: 12ch;
	margin: 1rem 0 1.25rem;
	font-size: clamp(2.1rem, 4.5vw, 4.6rem);
	font-weight: 650;
	line-height: 0.95;
}

.manifesto__standfirst,
.manifesto__chapter > p:last-of-type {
	max-width: 34rem;
	color: #a8ab9f;
	font-size: clamp(1rem, 1.5vw, 1.2rem);
	line-height: 1.65;
}

.end-mark {
	align-self: flex-end;
	margin-top: 3rem;
	padding: 0.55rem 0.7rem;
	border: 1px solid var(--route-color);
}

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

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

/* Buyer API: circular ring */
.progress-circle {
	position: fixed;
	right: 2rem;
	bottom: 2rem;
	z-index: 30;
	width: 76px;
	aspect-ratio: 1;
	transition: opacity 160ms linear;
}

.progress-circle--top-right { top: 2rem; bottom: auto; }
.progress-circle--top-left { top: 2rem; right: auto; bottom: auto; left: 2rem; }
.progress-circle--bottom-left { right: auto; left: 2rem; }
.progress-circle__svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.progress-circle__bg,
.progress-circle__fill,
.progress-circle__crosshair { fill: none; }
.progress-circle__bg { stroke: var(--line); stroke-width: 1.5; }
.progress-circle__fill {
	stroke: var(--route-color, var(--accent));
	stroke-width: 3;
	stroke-linecap: square;
	stroke-dashoffset: 327;
	will-change: stroke-dashoffset;
}
.progress-circle__crosshair { stroke: rgba(222, 223, 213, 0.32); stroke-width: 1; }
.progress-circle__text {
	position: absolute;
	inset: 0;
	display: grid;
	place-items: center;
	font-family: 'JetBrains Mono', monospace;
	font-size: 0.68rem;
	color: var(--route-color, var(--accent));
}

.instrument__dial.progress-circle {
	position: relative;
	right: auto;
	bottom: auto;
	z-index: auto;
	width: min(16vw, 172px);
	min-width: 126px;
}

/* Buyer API: side rail */
.progress-rail {
	position: fixed;
	top: 0;
	right: 0;
	z-index: 30;
	width: 3px;
	height: 100vh;
	background: var(--line);
	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(--route-color, var(--accent));
	transform: scaleY(0);
	transform-origin: center top;
	will-change: transform;
}
.route > .progress-rail {
	position: absolute;
	left: 0;
	right: auto;
	z-index: 0;
	height: 100%;
}

/* Buyer API: percentage counter */
.progress-counter {
	position: fixed;
	bottom: 2rem;
	left: 2rem;
	z-index: 30;
	display: flex;
	align-items: baseline;
	gap: 0.18em;
	font-family: 'JetBrains Mono', monospace;
	transition: opacity 160ms linear;
}
.progress-counter--top-left { top: 2rem; bottom: auto; }
.progress-counter--top-right { top: 2rem; right: 2rem; bottom: auto; left: auto; }
.progress-counter--bottom-right { right: 2rem; left: auto; }
.progress-counter__prefix,
.progress-counter__symbol { color: var(--muted); font-size: 0.65rem; }
.progress-counter__value {
	min-width: 3ch;
	color: var(--route-color, var(--accent));
	font-size: 2rem;
	font-weight: 600;
	line-height: 1;
	text-align: right;
}
.instrument__readout .progress-counter {
	position: static;
	z-index: auto;
}

/* The semantic switcher isolates a channel without changing the stage. */
body:not([data-mode='all']) .instrument__dial,
body:not([data-mode='all']) .route,
body:not([data-mode='all']) .progress-counter,
body:not([data-mode='all']) > .progress-bar { opacity: 0.12; }
body[data-mode='bar'] > .progress-bar,
body[data-mode='circle'] .instrument__dial,
body[data-mode='rail'] .route,
body[data-mode='counter'] .progress-counter { opacity: 1; }

@media (max-width: 760px) {
	.showcase-toolbar {
		height: 44px;
		padding: 0 0.75rem;
		gap: 0.5rem;
	}
	.showcase-prompt { font-size: 0.55rem; }
	.mode-switcher button { min-width: 2rem; padding-inline: 0.35rem; font-size: 0.55rem; }
	.field-note__layout { display: block; padding: 0 1.1rem; }
	.instrument {
		top: 44px;
		z-index: 20;
		height: 174px;
		grid-template-columns: 76px 1fr auto;
		grid-template-rows: auto 1fr;
		gap: 0.65rem 1rem;
		padding: 0.8rem 0;
		border-right: 0;
		border-bottom: 1px solid var(--line);
		background: rgba(8, 9, 8, 0.96);
		backdrop-filter: blur(12px);
	}
	.instrument::before,
	.instrument::after { display: none; }
	.instrument__topline { grid-column: 1 / -1; padding-right: 0; }
	.instrument__dial.progress-circle { grid-column: 1; width: 70px; min-width: 0; align-self: center; }
	.route { grid-column: 2; min-height: 92px; }
	.route__stops { padding-right: 0; font-size: 0; }
	.route__stops span { font-size: 0.52rem; }
	.instrument__readout {
		grid-column: 3;
		align-self: center;
		flex-direction: column;
		align-items: flex-end;
		justify-content: center;
		padding-right: 0;
	}
	.instrument__chapter strong { font-size: 0.92rem; }
	.instrument__chapter span { font-size: 0.48rem; }
	.progress-counter__value { font-size: 1.35rem; }
	.manifesto { width: 100%; }
	.manifesto__chapter,
	.manifesto__chapter--lead,
	.manifesto__chapter--final {
		min-height: 54svh;
		padding: 3.5rem 0;
	}
	.manifesto__chapter--lead { min-height: 68svh; padding-top: 5rem; }
	.manifesto h1 { font-size: clamp(2.8rem, 14vw, 4.5rem); }
	.manifesto h2 { font-size: clamp(2rem, 10vw, 3.4rem); }
	.manifesto__standfirst,
	.manifesto__chapter > p:last-of-type { font-size: 1rem; line-height: 1.55; }
}

@media (max-width: 420px) {
	.showcase-prompt { max-width: none; white-space: nowrap; line-height: 1.15; }
	.mode-switcher { gap: 0; }
	.mode-switcher button { min-width: 1.75rem; }
}

@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;
	}
}
```

### assets/script.js

```js
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 instrument always lands on 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));
		});

		const chapters = Array.from(document.querySelectorAll('[data-chapter]'));
		const chapterLabel = document.querySelector('[data-current-chapter]');
		const routeStops = Array.from(document.querySelectorAll('[data-route-stop]'));
		const stage = document.querySelector('.field-note');

		if (stage && chapters.length) {
			let activeIndex = -1;
			const chapterTrigger = ScrollTrigger.create({
				trigger: document.documentElement,
				start: 'top top',
				end: 'bottom bottom',
				onUpdate: syncFieldNote,
				onRefresh: syncFieldNote
			});

			function syncFieldNote(self) {
				const progress = self.progress >= 0.9995 ? 1 : self.progress;
				const nextIndex = progress === 1
					? chapters.length - 1
					: Math.min(chapters.length - 1, Math.floor(progress * chapters.length));
				stage.style.setProperty('--route-color', gsap.utils.interpolate('#ff6b1a', '#c8ff00', progress));
				if (nextIndex === activeIndex) return;
				activeIndex = nextIndex;
				if (chapterLabel && chapterLabel.isConnected) {
					chapterLabel.textContent = chapters[activeIndex].dataset.chapter;
				}
				routeStops.forEach(function(stop, index) {
					stop.classList.toggle('is-active', index === activeIndex);
					if (index === activeIndex) stop.setAttribute('aria-current', 'step');
					else stop.removeAttribute('aria-current');
				});
			}

			syncFieldNote(chapterTrigger);
			instances.push({ destroy: function() { chapterTrigger.kill(); } });
		}

		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.kill();
	}

	window.ScrollProgress = ScrollProgress;
	window.gsapContext = ctx;
	window.destroyScrollProgress = destroy;
	window.addEventListener('beforeunload', destroy, { once: true });
});
```

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
