GODRICH

9 CSS Scroll Progress Bar Ideas, Copy-Paste

A css scroll progress bar reads how far a reader has scrolled and turns that into a fill, a ring, a count, or a countdown — nine of them here, no JS,

Auto-plays · click a tile to jump to its section · all nine in one zip

The order below isn't ranked by how common each one is — it follows where a reader's eye actually lands. 01 through 04 and 06 sit at the screen's own edges or corners, noticed without anyone deciding to look. 05 sits inside the content itself, as part of a headline. 07 through 09 go a step further and describe the article's own structure — which chapter, which heading, how much time is left. Six of the nine tie their motion directly to animation-timeline: scroll(); the other three — 03, 07, and 08 — key off which section or chapter is on screen instead, and the fallback for browsers that don't support scroll-driven animations yet is covered in the trap section below.

01Top thin bar

A 3px strip pinned to the very top of the page fills from the left as the reader scrolls down, and nothing else on the page moves to make room for it. It's the shape most tutorial sites and long-form articles reach for first, since one thin line at the very top costs almost no layout space.

scroll()3px왼쪽 정렬
.bar {
  width: 100%;
  transform: scaleX(0);
  transform-origin: left;

  @supports (animation-timeline: scroll()) {
    animation: fill linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes fill { to { transform: scaleX(1); } }

02Circular ring badge

A round badge floating at the bottom-right corner fills as a conic-gradient ring instead of a straight bar, so it can sit on top of other content without stretching across the page. It pairs naturally with a floating back-to-top button, letting one element handle both navigation and progress.

conic-gradient@property고정 위치
@property --p {
  syntax: '<number>';
  initial-value: 0;
  inherits: false;
}

.badge {
  --p: 0;
  border-radius: 50%;
  background: conic-gradient($color calc(var(--p) * 1%), $track 0);

  @supports (animation-timeline: scroll()) {
    animation: fill-ring linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes fill-ring { from { --p: 0; } to { --p: 100; } }

03Section dot nav

A vertical column of dots sits fixed at the screen's edge, and only the dot for whatever section is currently on screen grows and lights up. Because this one tracks which section is visible rather than a single scroll percentage, the live version swaps animation-timeline for an IntersectionObserver watching each section directly.

scale 1.6배점 6개세로 정렬
const io = new IntersectionObserver((entries) => {
  const visible = entries
    .filter((e) => e.isIntersecting)
    .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
  if (visible) setActive(sections.indexOf(visible.target));
}, { threshold: [0.3, 0.6] });
sections.forEach((el) => io.observe(el));

04Side vertical rail

A thin track running down the right edge of the screen fills from top to bottom, mirroring the top bar but along the vertical axis instead. Docs sites with a fixed-width column often park it in the margin, where a horizontal bar would have nowhere natural to sit.

scaleY우측 4px고정
.rail__fill {
  height: 100%;
  transform: scaleY(0);
  transform-origin: top;

  @supports (animation-timeline: scroll()) {
    animation: fill-y linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes fill-y { to { transform: scaleY(1); } }

05Text fill

A headline's own color fills in from the left in step with how much of the page has been read, drawn entirely with background-clip: text instead of an image or an overlay. It works best on a hero headline or an article title that's already large enough for the gradient edge to read clearly.

background-clip:textlinear-gradient두 색
@property --p {
  syntax: '<percentage>';
  initial-value: 0%;
  inherits: false;
}

.headline {
  --p: 0%;
  background-image: linear-gradient(90deg, $color var(--p), $dim var(--p));
  background-clip: text;
  -webkit-background-clip: text;
  color: transparent;

  @supports (animation-timeline: scroll()) {
    animation: fill-text linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes fill-text { from { --p: 0%; } to { --p: 100%; } }

06Number percent

A small number tucked in the top-right corner counts from 0 to 100 as the page scrolls, registered through @property so the browser tweens whole integers instead of decimals. Long surveys and multi-page tutorials use it where readers expect an exact figure rather than a visual bar.

@property정수 보간% 붙이기
@property --num {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

.counter__num {
  --num: 0;
  counter-reset: num var(--num);
  &::before { content: counter(num) '%'; }

  @supports (animation-timeline: scroll()) {
    animation: count-up linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes count-up { from { --num: 0; } to { --num: 100; } }

07Chapter steps

A row of numbered circles at the top of the page fills in one at a time as the reader passes through each chapter, so the whole structure of a long guide is visible at a glance. Each circle runs its own @keyframes block tied to a slice of the document's scroll range rather than one shared animation.

원 채움챕터 4개scale 1.25배
@keyframes fill-2 {
  0%, 25%    { background: transparent; transform: scale(1); }
  33%        { background: $color; transform: scale(1.25); }
  40%, 100%  { background: $color; transform: scale(1); }
}
.step:nth-child(2) .step__circle {
  animation: fill-2 linear both;
  animation-timeline: scroll(root);
}

08TOC highlight

Inside a sidebar table of contents, a pill-shaped background slides between rows to mark whichever heading the reader is currently under, rather than lighting up every visited link. The pill first shipped as a solid, saturated fill, but that broke text contrast during the slide between two rows. Swapping it for a 14% tint with a left border fixed the contrast without losing the highlight.

translateY 알약현재 항목만좌측 보더 3px
.toc__pill {
  position: absolute;
  background: rgba(47, 109, 246, .14);
  border-left: 3px solid $color;
  animation: move $dur-loop ease-in-out infinite;
}

09Reading time left

A "N min left" readout counts down as the page scrolls, subtracting from the total estimated reading time in proportion to how much has already gone by. Newsletter web versions and long article headers use it instead of a bar, since a shrinking number reads as a concrete promise rather than an abstract fill.

카운트다운분 단위scroll() 연동
@property --min {
  syntax: '<integer>';
  initial-value: 12;
  inherits: false;
}

.time__num {
  --min: 12;
  counter-reset: min var(--min);
  &::before { content: counter(min); }

  @supports (animation-timeline: scroll()) {
    animation: count-down linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes count-down { from { --min: 12; } to { --min: 0; } }

Where does this break?

animation-timeline: scroll() is still a moving target. Measured against caniuse on 2026-09-10, it ships in Chrome and Edge from version 115 and in Safari from version 26 (released 2025-09), while Firefox only carries version 158 behind a flag as of this writing — MDN's animation-timeline reference tracks the current state. Six of the nine files wrap their moving rule in @supports (animation-timeline: scroll()). The other three — 03, 07, and 08 — read the scroll position with plain JavaScript instead, since each tracks which section, chapter, or heading is active rather than one continuous percentage. The zip's react/ folder ships a JS-driven twin of every item regardless: a scroll listener for the six built on animation-timeline, Math.floor on the scroll ratio for 07, and an IntersectionObserver for 03 and 08 — so a Firefox visitor still sees the number climb even where the CSS-only version would sit frozen. One trap turned up while rendering these files for real rather than just reading the code: building item 07's chapter fill as a single instant steps(1) swap left only four frames out of twenty-three with any visible difference between them, low enough that the motion measurement script flagged it as barely moving at all. Adding the scale(1.25) pop at each threshold — instead of a flat color swap — spread that change across sixteen of the twenty-three frames, which is what made the step actually read as movement rather than a flicker. The nine files with that fix already applied sit in the archive, and it opens with 3rmg234a typed just as this sentence prints it, with nothing added before or after.

Feature Chrome / Edge Safari Firefox
animation-timeline: scroll() 115+ 26+ (2025-09) 158+, flagged as of 2026-09
@property 85+ 16.4+ 128+

Accessibility

Seven of the nine answer prefers-reduced-motion: reduce by snapping to the finished state — fully read — rather than freezing partway through, since a progress indicator stuck at 40% under reduced motion would misreport where the page actually stands. The two that track a specific section instead — 03's dot nav and 08's TOC pill — settle on the first stop rather than the last. That's the natural resting point for an indicator that was never a percentage to begin with. Items 06 and 09 print their numbers through counter(), which screen readers skip over entirely. So the digit itself carries aria-hidden while the wrapper around it carries role="status" instead, announcing the change without asking a screen reader to parse a moving digit. Item 08 doubles as real navigation, built from actual <a href="#section-id"> links rather than plain divs, so keyboard focus reaches every heading a mouse click would. Item 03's dots stay purely decorative instead, marked aria-hidden inside a labeled nav, since an unlabeled row of dots would announce as confusing buttons rather than named stops.

@media (prefers-reduced-motion: reduce) {
  .bar, .rail__fill, .headline, .counter__num, .time__num {
    animation: none !important;
  }
  .bar { transform: scaleX(1); }
  .rail__fill { transform: scaleY(1); }
  .headline { --p: 100%; }
  .counter__num { --num: 100; }
  .time__num { --min: 0; }
}

MDN's prefers-reduced-motion page documents which operating-system settings flip this media query on.

More scroll-driven CSS lives on the CSS category hub, and what this site does and doesn't sell is on the about page.

FAQ

Do any of these nine actually need JavaScript?

Only indirectly. Every demo above runs on animation-timeline: scroll() alone, with zero lines of JS in the CSS-only files themselves. The zip's react/ folder adds a JS-driven twin of each one anyway, since 03's section detection and browsers that haven't shipped scroll timelines yet both need a script fallback to keep moving.

Which one should I pick if my page only has three headings?

01's top bar or 04's side rail, since both read the whole document's scroll position rather than distinct sections. Items 03, 07, and 08 all depend on dividing the page into several chunks, and with only three headings the jumps between them read as sudden rather than gradual.

Can I combine more than one of these on the same page?

Yes — none of the nine claim the same spot. 06's number and 02's ring badge are the only two anchored to a screen corner, and they sit in opposite ones, top-right and bottom-right, so even those two pair cleanly. 01 and 04 run along opposite edges, and the rest sit inline in the content, so nothing here forces you to pick just one.

Enter the archive password

The password is inside this article. You will find it as you read.