Scroll Indicator
A progress bar pinned to the top of a scroll area that fills as you read — one CSS animation bound to the container's scroll, no scroll listener.
scroll timelines
A reading-progress bar normally ships as a scroll listener: read scrollTop, divide by
scroll height, set a width every frame. It is one animation-timeline declaration now.
How it works
- The box is an ordinary scroll container:
h-64 overflow-y-autogives it a fixed height and lets the content scroll. That part works everywhere. - The rail is a
sticky top-0strip that stays pinned to the top edge of the box while the content scrolls under it. Inside it, the fill starts atw-0and grows rightward. - The fill’s
[animation-timeline:scroll()]binds its@keyframesanimation to the box’s scroll position instead of a clock —scroll()resolves to the nearest scrolling ancestor, which is the box, not the page. Scrolling top to bottom scrubs it fromwidth: 0towidth: 100%. - The keyframes animate
width, nottransform: scaleX(): scaling arounded-r-fullfill warps its cap, so the rounded end would change shape as you scroll.widthkeeps the radius constant, and the layout cost of one thin bar is nothing. [animation-duration:auto]maps the animation’s length to the scroll range, so the bar reads empty at the top and full at the bottom. It’s a longhand on purpose — Tailwind’sanimate-[…]shorthand silently drops theautokeyword.- Because
scroll()picks the nearest scroller by context, the same fill becomes a page-level reading indicator with no rule change: move it out of the box and make itfixedat the top of the viewport, andscroll()now resolves to the page.
Accessibility
- The rail is
aria-hidden="true". It only visualizes scroll position, which the platform already reports through the scrollbar and to assistive tech, so announcing it would add no information a screen-reader user is missing. - Keyboard and pointer scrolling are untouched: the indicator is a passive overlay and never intercepts input.
- The global
prefers-reduced-motionrule capsanimation-duration, which a scroll-timeline animation ignores — its progress comes from scroll position, not time. No per-component override is needed: the bar only ever moves as fast as the user scrolls, so it is not autonomous motion.
The fallback story
animation-timeline: scroll() is not Baseline yet (Chrome and Safari ship it; Firefox
still keeps it behind a flag). The rail is hidden and only revealed by
supports-[animation-timeline:scroll()]:block, so a browser without the feature never
shows an empty or stuck bar — it just gets an ordinary scroll box. The indicator enhances;
it never carries.
Why the CSS block
@keyframes has no utility syntax. The markup can name the animation —
[animation-name:grow-progress] — but the frames themselves must be written as CSS. The
block is that one rule and does nothing else, the same escape hatch
Marquee and Carousel use. For a thin
scrollbar and fade edges on the scroll box itself, see Scroll Area.
Browser support
Minimum stable version per engine, resolved at build time from MDN's browser-compat-data; Baseline status from the official web-features dataset. Everything degrades gracefully — the “when missing” column is the actual behavior, not a broken page.
| Feature | Chrome | Edge | Firefox | Safari | When missing |
|---|---|---|---|---|---|
| Scroll-driven animations (animation-timeline) | 115 | 115 | ✕ | 26 | The scroll-linked animation never runs; the element stays in its base state and the container still scrolls normally. |