Scroll-linked effects that preserve native scrolling
Drive a scroll-linked effect from the scroller’s actual position, while leaving scrolling itself to the browser. Prefer a scroll timeline for a decorative CSS effect, keep a small position-reading fallback when needed, and avoid cancelling wheel or touch input merely to update a progress bar.
Choose the scroller before the effect
The example has one reading area with three sections and a smaller packing list inside the first section. The reading area owns the decorative progress bar. The packing list has its own scrollable content; its position is not a second input to the outer bar.
Naming that relationship prevents a common ambiguity. “Page progress” might mean the whole document, a particular reading pane or a chapter entering the viewport. This component means the reading pane’s available vertical scroll range. It does not estimate whether the reader has understood the text or visited every section.
Both scroll areas are ordinary overflow containers. There is no drag controller, wheel interception or scripted imitation of scroll momentum. The links to Arrival and Departure remain ordinary fragment links. The effect observes the document’s navigation rather than replacing it with a separate interaction model.
Let a timeline describe progress
The reading area declares a named scroll timeline. In timeline mode, the bar uses that timeline for a scale animation from zero to one. MDN describes a scroll-progress timeline as advancing with the position of its selected scroller.1
Declare animation-timeline after the animation shorthand. The shorthand resets the timeline selection, so reversing those declarations can leave an animation attached to the default time-based timeline instead.1 This order is part of the implementation, not a cosmetic formatting choice.
The bar is sticky at the top of the pane and uses its left edge as its transform origin. Scaling changes that decorative strip; it does not change the section heights or the reader’s position. The bar is also hidden from the accessibility tree because it is not the only way to understand or navigate the content.
Before selecting this mode, the script checks whether the browser accepts a scroll-timeline declaration. When it does not, the timeline option is disabled and the listener fallback is selected. That support check chooses a path; it does not provide a rendering-speed result or prove every interaction case.
Read position in the fallback
The listener branch divides scrollTop by scrollHeight - clientHeight, then clamps the fraction between zero and one. If there is no positive scroll range, it chooses zero rather than dividing by zero. This calculation defines the fallback’s progress value directly from the reading area.
The scroll event describes a change that has already happened to the element’s scroll position.2 The handler schedules one pending update with requestAnimationFrame; another event while it is pending does not schedule a second callback. The callback then reads the current position, rather than replaying every intermediate event.
That pending flag is a scheduling choice, not evidence that the callback runs less often than scrolling or that it improves frame duration. Keep the work small and measure the actual page before making a performance comparison. This example has one rectangle to scale and no application data fetch in the callback.
The complete example
Save this original MIT-licensed HTML as a local file. The selector chooses the timeline, listener or no-effect route without replacing the reading content. The demo contains the same component with its procedure.
<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Progress from native scrolling</title>
<style>
#reader { height: 20rem; max-width: 32rem; overflow: auto; border: 2px solid #345; scroll-timeline: --reading block; }
#bar { position: sticky; top: 0; height: 0.4rem; background: #045ac2; transform: scaleX(0); transform-origin: left; }
#reader.timeline #bar { animation: progress linear both; animation-timeline: --reading; }
#reader.quiet #bar { visibility: hidden; }
.chapter { min-height: 18rem; padding: 1rem; }
#nested { height: 5rem; overflow: auto; border: 1px solid #345; }
#nested p { min-height: 10rem; padding: 0.5rem; }
@keyframes progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@media (prefers-reduced-motion: reduce) { #bar { visibility: hidden; } }
:focus-visible { outline: 3px solid #045ac2; outline-offset: 3px; }
</style>
</head>
<body>
<label for="mode">Progress effect</label>
<select id="mode">
<option value="timeline">CSS scroll timeline</option>
<option value="listener">Scroll listener fallback</option>
<option value="none">No effect</option>
</select>
<div id="reader" tabindex="0" aria-label="Reading area">
<div id="bar" aria-hidden="true"></div>
<section class="chapter" id="start">
<h2>Departure</h2>
<a id="end-link" href="#end">Go to arrival</a>
<div id="nested" tabindex="0" aria-label="Packing list">
<p>Map, water, notebook, spare layers. Scroll this list separately.</p>
</div>
</section>
<section class="chapter">
<h2>On the route</h2>
<p>The document supplies its own scrollable height.</p>
</section>
<section class="chapter" id="end">
<h2>Arrival</h2>
<a id="start-link" href="#start">Return to departure</a>
</section>
</div>
<button id="after">Continue outside the reading area</button>
<script>
const reader = document.querySelector('#reader');
const bar = document.querySelector('#bar');
const mode = document.querySelector('#mode');
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
let pending = 0;
function update() {
pending = 0;
if (mode.value !== 'listener' || reduced.matches) return;
const range = reader.scrollHeight - reader.clientHeight;
const progress = range > 0 ? Math.max(0, Math.min(1, reader.scrollTop / range)) : 0;
bar.style.transform = `scaleX(${progress})`;
}
function configure() {
cancelAnimationFrame(pending);
pending = 0;
bar.style.removeProperty('transform');
reader.classList.toggle('timeline', mode.value === 'timeline');
reader.classList.toggle('quiet', mode.value === 'none' || reduced.matches);
update();
}
reader.addEventListener('scroll', () => {
if (mode.value === 'listener' && !pending) pending = requestAnimationFrame(update);
}, { passive: true });
mode.addEventListener('change', configure);
reduced.addEventListener('change', configure);
window.addEventListener('resize', update);
if (!CSS.supports('animation-timeline: scroll()')) {
mode.options[0].disabled = true;
mode.value = 'listener';
}
configure();
</script>
</body>
</html>
Walk through the input routes
- Scroll the reading area. In timeline mode, CSS supplies the bar’s progress. In listener mode, the scheduled callback reads that area’s current position and writes one transform. Neither path assigns a new scroll position.
- Follow a section link. Let the fragment navigation move the relevant content into view. The same timeline or position calculation then describes where the pane ended up; the link needs no special animation callback.
- Scroll the nested list. Its own content moves within its own boundary. The outer effect is defined by the outer position, so an inner movement alone is not a reason to advance the outer bar.
- Switch modes partway through. Cancel any pending callback, remove its inline transform and apply the selected mode. Recalculate the listener value when entering that branch. Do not carry an old inline transform into a supposedly CSS-controlled effect.
- Choose No effect. Hide the decoration. Leave the reading area, its links, the nested list and the outside button available. The content task does not require the bar to be present.
A window resize requests another fallback calculation because the available scroll range may change. This example’s section contents are fixed. If an application inserts content later, it needs to request an update for that change or choose another way to observe the relevant dimensions.
Do not confuse wheel input with scrolling
A wheel event is an input event, while a scroll event reports position change. Wheel input can also request zoom, and scrolling can come from keyboard or other navigation. The MDN wheel reference warns against deriving the scroll direction solely from wheel deltas.3 Use the scroller’s position for this effect.
The fallback registers its scroll listener as passive and never calls preventDefault. It installs no wheel listener at all. For a different component that genuinely needs to cancel an input event, passive: true is incompatible with that cancellation; it is not a flag to add while keeping the same cancellation code.4
Do not add a wheel handler just to prove that a pane is interactive. It changes the event path without contributing anything to this progress calculation. A custom drag or zoom tool needs its own input requirements and a reason to take control away from the native behavior.
Keep reduced motion independent of navigation
The media query hides the bar when reduced motion is requested. The script also skips listener updates for that preference and reconfigures the effect when it changes. Scrolling and links stay governed by the content, not by the permission to show a decorative change.
The mode selector also offers a direct No effect choice. A site may keep that choice in its own preference controls rather than beside every pane. In either placement, make the choice affect the effect itself instead of disabling the reading area.
For timing investigations, record a named metric such as frame duration or long animation frames, the browser, device class and refresh rate. A progress bar reaching the expected endpoint is a correctness check, not a measurement of those costs. Use the animation metrics reference to define a separate measurement before comparing implementations.
Not covered on this page
- Custom drag scrolling, wheel-driven zoom or scroll locking.
- Infinite content, reading-completion tracking or analytics.
- Cross-origin frames or a complete nested-scroll chaining policy.
- Accessibility certification or comparative rendering benchmarks.
Sources
-
MDN, animation-timeline. Living documentation, opened 24 October 2026. ↩ ↩2
-
MDN, Element scroll event. Living documentation, opened 24 October 2026. ↩
-
MDN, Element wheel event. Living documentation, opened 24 October 2026. ↩
-
MDN, EventTarget.addEventListener(). Living documentation, opened 24 October 2026. ↩
Code on this page is original and MIT licensed.
How we test: methods. Found a mistake? Corrections.