ScrollFree

Chapter 1: Introduction

A 280 px scrolling box, a 4 px track stuck to its top and a gauge whose scaleX follows the scroll in CSS alone: scroll-timeline: --progressScroll y on the box, animation-timeline: --progressScroll on the gauge — measured scaleX = scrollTop ÷ scroll range, to the thousandth. Zero JavaScript; Firefox shows the bar full from load.

CSSscroll()Progress

Updated

Chapter 1: Introduction

Welcome to this demonstration of a scroll-linked progress bar. The bar at the top of this container progresses based on your scroll position.

This effect uses the CSS property animation-timeline: scroll() which links a @keyframes animation directly to the container's scroll position.

Chapter 2: How It Works

The scroll-timeline property creates a named timeline on the scrollable container. The progress-bar animation then uses this timeline to animate the transform: scaleX() property.

The result is a smooth progress bar that accurately represents the percentage of content read, without any JavaScript.

Chapter 3: Applications

Ideal for blog posts, documentation pages, tutorials, or any long-form content where the user wants to visualize their reading progress.

Compatible with modern browsers supporting Scroll-Driven Animations (Chrome 115+, Edge 115+).

Chapter 4: Conclusion

Scroll-linked animations open new possibilities for user experience, all in pure CSS with no impact on JavaScript performance.

Scroll to see the effect
Hover or click the scene to interact

This effect is free — the Scroll category contains 70 effects total, including 12 free. Effect.Labs has 811 vanilla effects. Explore the category →

Usage examples

Saint-Louis Clinic
Waiting room · Orthopaedics
Tablet 3
Information sheet · Knee arthroscopy
Before your procedure
Dr Léa Morvan · Orthopaedic surgery · 4-minute read

Preparation

Nothing to eat or drink from midnight, no chewing gum either. Take your usual medication with a sip of water, except blood thinners, which are stopped as instructed by the surgeon.

Bring your blood group card, your X-rays and the signed consent form. Remove jewellery, nail polish and contact lenses the evening before.

The day itself

Report to the day-surgery desk at the time printed on your appointment letter, with someone to accompany you. The procedure lasts 30 to 45 minutes under regional anaesthesia.

You will stay in the recovery room for about an hour, then in your room until the anaesthetist's visit.

Afterwards

Ice for 20 minutes every two hours for three days, leg raised. Crutches are advised for 48 hours; walking without full weight-bearing is possible from the next day.

The dressing is changed on day three by the nurse; the stitches come out on day ten.

Who to call

Pain that does not ease, fever above 38.5 °C, a hard or swollen calf: call without waiting.

02 31 00 00 00Surgery office · 8 am – 6 pm
02 31 00 00 15Clinic emergency line · 24/7

① Pre-procedure information sheet — waiting-room tablet in a clinic

WhenLocked tablet in a waiting room: chapters “Preparation”, “The day itself”, “Afterwards”, “Who to call”; the bar at the top reassures about how much is left.
WhyThe gauge is not inside the sheet: it is glued to the bottom edge of the tablet's white header, which does not scroll, and yet it reads the text box scrolling below it. The box declares the timeline, the scene frame carries timeline-scope: --progressScroll, and the gauge, housed in the header next to the box, finds it by name. With scroll(nearest) (fx-0555), a gauge only sees its ancestors: placed in the header, it would not read the sheet; it would have to go back inside the box, under the header.
SettingsLight theme, 58 px fixed header carrying the sold track at its bottom (position: absolute; bottom over the sticky), #0f766e → #14b8a6 gauge, box pinned below; the gauge is a sibling of the box thanks to timeline-scope: --progressScroll on the scene frame. 13 px text, two phone cards at the end of the sheet. Starts at 22%, advances on its own at 48 px/s until the first gesture — a demo device.
Getting started guide v2.3 · built from docs/start.md

Four chapters, in the order of a signal: from the socket to the recording.

01Inputs

Each channel accepts an XLR microphone or a 6.35 mm line jack. Engage 48 V for a condenser mic, PAD for a hot source, then raise the GAIN until the peak meter touches −18 dBFS.

GAIN 0 → 60 dB · 48V · PAD −20 dB · HPF 80 Hz

02Buses

Faders feed the main L/R mix or one of the four subgroup buses. Assign drums and backing vocals to a bus to control them with a single fader.

MAIN L/R · BUS 1-2 · BUS 3-4 · SOLO PFL/AFL

03Effects

Two internal engines (reverb, delay) are fed by the AUX 5 and AUX 6 sends. The effect return lands on channels 15-16 without using up an input.

AUX 5 → REV hall 2,4 s · AUX 6 → DLY 3/8 · RTN 15-16

04Recording

The USB interface sends all 16 channels as a multitrack stream at 24-bit / 48 kHz. Arm the tracks in your DAW and start recording from the console with REC.

USB 18 × 18 · 24 bit / 48 kHz · REC · PLAY · STOP

② Getting-started manual for a mixing console — an audio manufacturer's online documentation

WhenManual built from Markdown: chapters “Inputs”, “Buses”, “Effects”, “Recording” in a reading frame, progress bar in the manual's title bar.
WhyThe gauge lives in the manual's title bar, next to the guide's name and version, and reads the text frame scrolling below it through the timeline's name: what it shows is progress through the open chapter, from a template element that is not inside the text; fx-0555 (scroll(nearest)) binds the gauge to its nearest scrolling ancestor and would force it to live inside the text frame, under the title bar.
SettingsDark two-column theme: 132 px table of contents, 44 px title bar carrying the sold track at its bottom, gauge in the sold code's gradient, timeline-scope on the scene, box pinned under the title (top: 44px; bottom: 0). Numbered h3s, a monospace settings block under each chapter. Starts at 30%, advances at 44 px/s until the first gesture.
Bibliotheca · First-year Latin
Bilingual edition, facing text
Seneca, Letters to Lucilius, I, 1-3
Latin text
Epistula I

§ 1

Ita fac, mi Lucili: vindica te tibi, et tempus quod adhuc aut auferebatur aut subripiebatur aut excidebat collige et serva. Persuade tibi hoc sic esse ut scribo: quaedam tempora eripiuntur nobis, quaedam subducuntur, quaedam effluunt. Turpissima tamen est iactura quae per neglegentiam fit. Et si volueris attendere, magna pars vitae elabitur male agentibus, maxima nihil agentibus, tota vita aliud agentibus.

§ 2

Quem mihi dabis qui aliquod pretium tempori ponat, qui diem aestimet, qui intellegat se cotidie mori? In hoc enim fallimur, quod mortem prospicimus: magna pars eius iam praeterit; quidquid aetatis retro est mors tenet. Fac ergo, mi Lucili, quod facere te scribis, omnes horas complectere; sic fiet ut minus ex crastino pendeas, si hodierno manum inieceris.

§ 3

Dum differtur vita transcurrit. Omnia, Lucili, aliena sunt, tempus tantum nostrum est; in huius rei unius fugacis ac lubricae possessionem natura nos misit, ex qua expellit quicumque vult. Tanta autem stultitia mortalium est ut quae minima et vilissima sunt, certe reparabilia, imputari sibi cum impetravere patiantur, nemo se iudicet quicquam debere qui tempus accepit, cum interim hoc unum est quod ne gratus quidem potest reddere.

Translation
Letter I

§ 1

Do as you say, my dear Lucilius: claim yourself for yourself, and the time that until now was taken from you, stolen, or slipped away, gather it up and keep it. Convince yourself that it is as I write: some moments are snatched from us, others taken away, others drain off. The most shameful loss, however, is the one that comes from carelessness. And if you care to notice, a large part of life slips by while we do wrong, the largest while we do nothing, the whole of it while we do something else.

§ 2

Whom will you show me who puts a price on time, who values a day, who understands that he dies every day? For here is our mistake: we look at death ahead of us, while a great part of it has already gone by; whatever lies behind us belongs to death. Do then, my dear Lucilius, what you write that you are doing: embrace every hour; that way you will depend less on tomorrow if you lay hold of today.

§ 3

While it is put off, life runs by. Everything, Lucilius, belongs to others; time alone is ours. Nature put us in possession of this single fleeting and slippery thing, from which anyone who wishes may drive us out. And such is the folly of mortals that they accept being charged for the smallest and cheapest things, at least the replaceable ones, once they have obtained them, while no one considers himself to owe anything for having received time, the one thing that even a grateful man cannot give back.

③ Seneca's letter in a bilingual edition, Latin and translation side by side — a university's Latin platform

WhenTwo-column reading page: the Latin text on the left, the translation on the right, each column scrolling on its own; in each column's header, next to its label, a small gauge tells where that column is.
WhyTwo boxes, two gauges, each outside its box in its column's header: with timeline-scope: --progressScroll set on each column, the gauge binds by name to its own column's box and to that one only (measured: Latin at 25% → 0.25, translation at 75% → 0.75). The reader sees at a glance whether the Latin and the translation are at the same point of the letter. fx-0555 (scroll(nearest)) binds the gauge to a scrolling ancestor: it would have to live inside the box; fx-0575 targets ids, one instance per page.
SettingsLight paper theme, 48 px banner, two 270 px grid columns, 34 px column header carrying the label and the sold track made inline (position: static, 96 px wide, rounded corners), burgundy → gold gauge (#8b2e2e → #c9973a), box pinned below (top: 34px; bottom: 0, 278 px tall). timeline-scope: --progressScroll on each column, not on the scene: measured, set on the common ancestor of the two boxes, both gauges stay at 0 — a name declared twice within one scope makes the timeline inactive. Georgia 12.5 px, three h3 “§ 1” to “§ 3” + three p per column; the Latin is 750 px of content (472 px range), the translation 847 px (569 px range). Latin starts at 16%, advances at 38 px/s; translation at 38%, 50 px/s — two speeds so the gauges drift apart; the first gesture on a box stops that box.

How it works

The .demo-preview frame (centered flex, 100% × 280px, overflow: hidden, position: relative, rgb(10, 10, 15) background, white text, sans-serif) holds the .progress-scroll-container box: 100% × 100%, overflow-y: auto, overflow-x: hidden, and the line that makes the effect, scroll-timeline: --progressScroll y — a named scroll timeline whose progress runs from 0 at the top of the box to 1 on its last scrollable pixel. Inside the box, the .slr-progress-bar-track (position: sticky; top: 0, 4px tall, white at 10%, z-index: 5) carries the .slr-progress-bar-fill gauge: 90° gradient #6366f1 → #d946ef → #f59e0b, transform-origin: left, transform: scaleX(0), animation: progressGrow linear both and animation-timeline: --progressScroll. Below, .progress-content (padding 16px 12px): four 14.4 px h3 in #a78bfa, seven 12.8 px p in white at 70%. Outside the box, span.slr-scroll-hint reading “Scroll to see the effect”, absolute 8 px from the bottom of the frame, 11.2 px, white at 40%, bounces from 0 to −4 px every 2 s. The JavaScript field is empty.

@keyframes progressGrow goes from scaleX(0) to scaleX(1). With animation-timeline, the animation's time is no longer the clock but the scroll position (computed duration auto); linear makes the width exactly proportional, both holds both ends. Measured in Chromium 151, blank page, white and dark backgrounds alike: scrollTop 0 / 31 / 61 / 92 / 122 px → scaleX 0 / 0.254 / 0.5 / 0.754 / 1, i.e. scrollTop ÷ range to the thousandth; WebKit 26.5 gives the same values, and the track stays 0 px from the top of the box at every position. The range depends on the width: at 1,264 px wide the four chapters take only 402 px, the range is 122 px and a single 120 px wheel notch takes the gauge to 0.984; at 374 px wide the text is 669 px tall, the range 389 px, and the notches give 0.308, 0.617, 0.925 then 1. The gauge measures the box's scroll range, not a reading time: a short text in a wide column is “read” in one notch.

Against scroll(nearest) (Progress Bar, fx-0555), the difference is resolution. An anonymous timeline binds the animated element to its nearest scrolling ancestor: the gauge has to live inside the box. A named timeline is resolved by name: the gauge finds --progressScroll on any ancestor, and with timeline-scope: --progressScroll set on a common ancestor it can be a sibling of the box — in a fixed header, a title bar. Measured, track taken out of the box and placed before it: same values 0 / 0.254 / 0.5 / 0.754 / 1 in Chromium 151 and WebKit 26.5. Two copies pasted on the same page stay independent (measured: second box at 60% → 0.599, the first at 0): each box declares its timeline, each gauge binds to the nearest one, no id and no script. Incomplete slr- prefix (progress-scroll-container, progress-content, @keyframes progressGrow / hintBounce unprefixed): check that an existing stylesheet doesn't already declare them.

Accessibility

  • prefers-reduced-motion not in the code: measured under emulation, the gauge still follows the scroll — acceptable, it only moves with the reader's gesture (WCAG 2.3.3 targets autonomous motion). The hint, however, keeps bouncing 4 px every 2 s (animation measured running): add @media (prefers-reduced-motion: reduce) { .slr-scroll-hint { animation: none } }.
  • Keyboard: Chromium 151 and Firefox 153 focus the box on Tab without a tabindex (measured: activeElement = .progress-scroll-container); Arrow Down moves 40 px (Chromium) or 51 px (Firefox), Page Down 285 px, End goes to the bottom. WebKit 26.5 does not focus the box (focus stays on body, arrows do nothing): add tabindex="0", role="region", an aria-label and a :focus-visible style.
  • Contrast (4.5:1 threshold): the paragraph in white at 70% on #0a0a0f renders rgb(182, 182, 183), 9.75:1; the #a78bfa h3 7.26:1; the hint in white at 40% 3.77:1 at 11.2 px — below the threshold, and hard-coded text: aria-hidden="true" or delete it. The empty track (white at 10%) is only 1.25:1 against the background, barely visible before the gauge grows: raise it to 20-25%. The gauge against the track holds 3.55:1 at worst (#6366f1), above the 3:1 of a non-text element.
  • Screen readers and fallback: the gauge is an empty div, nothing is announced, and a CSS animation cannot write aria-valuenow — treat it as decorative (aria-hidden="true" on the track): assistive technology already knows the reading position from the scrolling box itself. The h3s are content: use the level that follows your outline. Without animation-timeline (Firefox), nothing is hidden: measured, all 11 text blocks are visible and the box scrolls.

Browser compatibility

CSS only: scroll-timeline (named), animation-timeline, position: sticky, overflow-y, one gradient, two @keyframes. Zero dependencies, zero JavaScript. Scroll-driven animations: Chrome and Edge 115+, Safari and iOS 26+ (measured in WebKit 26.5, values identical to Chromium); timeline-scope, to take the gauge out of the box: Chrome 116+, Safari 26. Firefox: no version. Measured in Firefox 153: CSS.supports('animation-timeline: scroll()') returns false, the declaration is dropped, animation: progressGrow linear both becomes a 0 s clock animation whose both applies the final keyframe at once — the bar is full from load (scaleX 1, 1,264 px out of 1,264) and stays full at every position; the text is readable and the box scrolls. Nothing is hidden, the bar just doesn't inform.

Chrome 115+✓ Full
Firefox✓ No version: bar full and frozen, text readable
Safari 26+✓ Full (measured in WebKit 26.5)
Edge 115+✓ Full
Mobile iOS 26+✓ Full (touch scrolling of the box)
Android Chrome 115+✓ Full

The sold code has no fallback: in Firefox, Chrome before 115 and Safari before 26, the bar is full from load and never changes, the text stays readable and the box scrolls. For an empty track rather than a bar at 100%, add @supports not (animation-timeline: scroll()) { .slr-progress-bar-fill { animation: none } } — measured in Firefox 153: scaleX(0) at every position, no effect in Chromium. For real progress everywhere, you need the JavaScript of Introduction (fx-0575).

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="progress-scroll-container">
    <div class="slr-progress-bar-track">
      <div class="slr-progress-bar-fill"></div>
    </div>
    <div class="progress-content">
      <h3>Chapter 1: Introduction</h3>
      <p>Welcome to this demonstration of a scroll-linked progress bar. The bar at the top of this container progresses based on your scroll position.</p>
      <p>This effect uses the CSS property animation-timeline: scroll() which links a @keyframes animation directly to the container's scroll position.</p>
      <h3>Chapter 2: How It Works</h3>
      <p>The scroll-timeline property creates a named timeline on the scrollable container. The progress-bar animation then uses this timeline to animate the transform: scaleX() property.</p>
      <p>The result is a smooth progress bar that accurately represents the percentage of content read, without any JavaScript.</p>
      <h3>Chapter 3: Applications</h3>
      <p>Ideal for blog posts, documentation pages, tutorials, or any long-form content where the user wants to visualize their reading progress.</p>
      <p>Compatible with modern browsers supporting Scroll-Driven Animations (Chrome 115+, Edge 115+).</p>
      <h3>Chapter 4: Conclusion</h3>
      <p>Scroll-linked animations open new possibilities for user experience, all in pure CSS with no impact on JavaScript performance.</p>
    </div>
  </div>
  <span class="slr-scroll-hint">Scroll to see the effect</span>
CSS
.slr-scroll-hint {
  position: absolute;
  bottom: 8px;
  left: 50%;
  transform: translateX(-50%);
  font-size: 0.7rem;
  color: rgba(255, 255, 255, 0.4);
  pointer-events: none;
  z-index: 10;
  animation: hintBounce 2s ease-in-out infinite;
}

.progress-scroll-container {
  width: 100%;
  height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  position: relative;
  scroll-timeline: --progressScroll y;
}

.slr-progress-bar-track {
  position: sticky;
  top: 0;
  left: 0;
  width: 100%;
  height: 4px;
  background: rgba(255, 255, 255, 0.1);
  z-index: 5;
}

.slr-progress-bar-fill {
  height: 100%;
  background: linear-gradient(90deg, #6366f1, #d946ef, #f59e0b);
  transform-origin: left;
  transform: scaleX(0);
  animation: progressGrow linear both;
  animation-timeline: --progressScroll;
}

.progress-content {
  padding: 16px 12px;
}

.progress-content p {
  font-size: 0.8rem;
  line-height: 1.6;
  color: rgba(255, 255, 255, 0.7);
  margin-bottom: 12px;
}

.progress-content h3 {
  font-size: 0.9rem;
  color: #a78bfa;
  margin-bottom: 8px;
}

@keyframes hintBounce {

  0%,
  100% {
    transform: translateX(-50%) translateY(0);
  }

  50% {
    transform: translateX(-50%) translateY(-4px);
  }
}

@keyframes progressGrow {
  from {
    transform: scaleX(0);
  }

  to {
    transform: scaleX(1);
  }
}
JavaScript (fx-0565)

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Timeline name and axis (CSS — scroll-timeline: --progressScroll y, animation-timeline: --progressScroll) --progressScroll, y axis Both declarations must carry the same name; rename it freely. x binds the gauge to a horizontal scroll. Several copies do not need different names: measured, each gauge binds to its own box.
Gauge placement (CSS — timeline-scope, to add) sticky track at the top of the box, inside the box To take it out of the box (fixed header, title bar), set timeline-scope: --progressScroll on a common ancestor — measured identical (0 → 1) in Chromium 151 and WebKit 26.5. Without it, a gauge outside the box does not find the timeline and stays at scaleX(0).
Scroll range (CSS — animation-range, to add on .slr-progress-bar-fill) absent (0 → 100% of the range) animation-range: 0 50% fills the gauge at mid-range (measured: 0.499 at 25%, 1 from 50%); 20% 100% keeps it empty over the first fifth — to skip an introduction or a footer.
Box height (CSS — .demo-preview { height: 280px }, box at 100%) 280 px Range = content height − box height, read by the browser: nothing else to change. The shipped text gives a 122 px range at 1,264 px wide, 389 px at 374 px. For a whole page, declare the timeline on html.
Thickness, colors and curve (CSS — .slr-progress-bar-track, .slr-progress-bar-fill) 4 px, track white at 10%, gauge #6366f1 → #d946ef → #f59e0b, linear The gradient is drawn over the full width then revealed by scaleX, not stretched: the amber only shows near 100%. The track at 10% is only 1.25:1 against the background. Keep linear for a proportional gauge; ease-out would make it run ahead of the reader.
Content (HTML + CSS — .progress-content h3, p) 4 h3 at 14.4 px #a78bfa, 7 p at 12.8 px white at 70% Demo text: replace it with yours, the gauge reads the box, not the markup. The longer the content or the narrower the column, the slower the gauge.
“Scroll to see the effect” hint (HTML + CSS — .slr-scroll-hint) 8 px from the bottom of the frame, 4 px bounce every 2 s Hard-coded text, absolute relative to .demo-preview (without that frame it drops to the bottom of the page). Delete the span and the @keyframes hintBounce; otherwise aria-hidden="true".

FAQ

How does it differ from Introduction (fx-0575), which shows the same reading bar?

Same anatomy — 280 px box, 4 px track stuck to the top, h3 + p chapters — but two engines. fx-0575 computes in JavaScript: a scroll listener writes scrollTop ÷ (scrollHeight − clientHeight) into the gauge's width, with ids (#progress-scroll, #progress-fill) that limit the code to one instance per page; it works in Firefox 61+ and its percentage is a number the page can reuse. fx-0565 has not a single line of script: a named scroll-timeline, a scaleX computed by the browser with no listener, as many copies as you like, a gauge that can leave the box via timeline-scope — but Firefox shows it full. Pick fx-0565 when JavaScript is forbidden or absent (kiosk, static generator, locked CMS) and your audience is on Chrome, Edge or Safari; fx-0575 when you need Firefox or the number.

Why a named timeline rather than scroll(nearest), like Progress Bar (fx-0555)?

scroll(nearest) is anonymous: the animated element binds to its nearest scrolling ancestor, so the gauge must sit inside the box, and an intermediate element that scrolls would hook it to the wrong container. The named timeline is explicit: the box declares it, the gauge asks for it by name, resolution walks up the ancestors — or, with timeline-scope: --progressScroll on a common ancestor, extends to its whole subtree: the gauge can then be a sibling of the box, in a fixed header or a title bar. Measured, track placed before the box: values identical to the in-box version — that is how the three scenes on this page are built, the third with two columns and two gauges.

In Firefox the bar is full as soon as the page opens: is the code broken?

No: without animation-timeline, animation: progressGrow linear both becomes a 0 s clock animation and both applies the final scaleX(1) keyframe from the first paint (measured in Firefox 153: full gauge, frozen, text and scrolling intact; Chrome before 115 and Safari before 26 do the same). If you prefer an empty track to a bar that lies, add @supports not (animation-timeline: scroll()) { .slr-progress-bar-fill { animation: none } } — measured in Firefox: scaleX(0) everywhere, no change in Chromium. For real progress under Firefox, you need the JavaScript of fx-0575.