Component
progress
Demo
tokens.css,
base.css and progress.css. No scripts.
Source
Copy both files, or run npx nojsui add progress.
Browser support
Not yet in every major engine. The @supports fallback is what most visitors will get.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| Scroll-driven animations | limited | 115 | 115 | — | 26 | 115 | — | 26 |
Usage
A hairline bar pinned to the top edge of the viewport that fills as the reader
scrolls the page. Copy the two elements anywhere in <body> — the bar is
fixed, so where it sits in the document makes no difference.
<div class="sk-progress" aria-hidden="true" data-sk-motion="essential">
<div class="sk-progress__bar"></div>
</div>
Both attributes are load-bearing and neither is decoration; see
Accessibility notes for aria-hidden and
Reduced motion for data-sk-motion.
To sit below a fixed header, set the offset rather than writing a rule against the component:
:root {
--sk-progress-inset-block-start: 3.5rem;
}
This is a reading indicator, not a <progress> element. It has no value and
cannot be given one — for a determinate bar (an upload, a step count) use
breadcrumb, whose bar is computed from attr(data-step).
How it works
animation-timeline: scroll() binds the animation’s progress to how far the
nearest ancestor scroll container has been scrolled. For a fixed-position
element that container is the document. The animation therefore has no
duration in time at all: animation-duration: auto hands the timing to the
timeline, and the bar’s position is a pure function of the scroll offset. No
listener, no requestAnimationFrame, nothing on the main thread.
The bar grows by inline-size, not by a transform. The obvious way to
write this is scale: 0 1 → 1 1 on the bar, which is cheaper — it runs on
the compositor. It is also wrong in a right-to-left document unless you flip
transform-origin by hand, because transform-origin: left is physical and
CLAUDE.md rule 3 rules it out. Anchoring the root with inset-inline-start: 0
and animating the bar’s inline-size from 0% to 100% makes the bar start
at whichever edge is the start edge and grow toward the end edge. RTL and
vertical writing modes then need no special case. The cost is animating layout
instead of the compositor, which is acceptable for a single fixed element with
no children and no siblings that depend on its size.
Two ways this silently renders a permanently full bar
Both were hit while building it, and neither throws anything.
The animation shorthand resets animation-duration to 0s. A
scroll-driven animation with a zero duration finishes immediately, so the bar
renders complete and never moves. The file uses longhands with
animation-duration: auto; reveal.css documents the same trap.
inline-size: 0 in the bar’s base style is load-bearing. A scroll timeline
is inactive when its scroll container has no overflow — a page shorter than
the viewport, or a demo frame that does not scroll. An inactive timeline makes
the animation have no effect at all, so the element renders from its base style
rather than from the from keyframe. A block-level div with no inline size
fills its parent, so leaving it unset puts a full bar on every short page. Zero
is the honest resting state, and an active timeline overrides it on the first
frame.
Keyboard contract
None. The component contains no focusable elements, takes no input and is skipped by sequential navigation entirely.
| Key | Behaviour |
|---|---|
Tab | Passes over the bar; it is never a tab stop |
| Any other key | No effect |
The reader’s own scrolling — by keyboard, wheel, or otherwise — is the only thing that moves the bar, and that is handled by the browser.
Verified manually in VoiceOver and NVDA: yes — neither announces the bar, which is the intended result.
Accessibility notes
aria-hidden="true", and deliberately no role="progressbar". The role
requires aria-valuenow, and without script there is no way to update it, so
the bar would announce a value frozen at zero for the life of the page. A
control that reports a permanently wrong value is worse than one that reports
nothing. The native scrollbar already conveys scroll position to assistive
technology; this bar is a visual echo of it, so hiding it removes no
information from the accessibility tree.
The elements are empty, so nothing is lost by hiding them. If you put content
inside .sk-progress, you have made a different component and aria-hidden
becomes a bug — it would hide that content too.
Reduced motion
The bar keeps tracking under prefers-reduced-motion: reduce. That is why
the markup carries data-sk-motion="essential", which opts out of the global
clamp in base.css (ADR 0008).
Both halves of the justification matter:
- Clamping it would make it lie.
base.csssetsanimation-duration: 0.01msunderreduce. A scroll-driven animation so clamped finishes instantly, pinning the bar at 100% — so the accommodation would leave the bar claiming the reader had finished the page before they had started it. - There is no motion to accommodate. The bar never moves on its own. It moves only in direct response to scrolling the reader performed, which is the case WCAG 2.3.3 exempts. Scrolling a page already moves the entire viewport; a hairline tracking that same gesture adds nothing to it.
Opting out is rare and deliberate. toast is the only other component that
does it.
Degradation
| Feature used | Baseline status | Behavior without it |
|---|---|---|
animation-timeline: scroll() | limited — Chrome/Edge 115+, Safari 26+, no Firefox | Nothing renders. The bar has no size and no background outside the @supports block, so there is no empty track left behind. The scrollbar remains the indicator. |
Rendering nothing is the deliberate choice. A track that paints but can never
fill reads as a stalled download at the top of every page — worse than an
absent decoration, and it would be indistinguishable from a real regression in
a screenshot. The whole paint therefore lives inside
@supports (animation-timeline: scroll()).
Theming
| Property | Default | Controls |
|---|---|---|
--sk-progress-thickness | --sk-space-3xs | Thickness of the bar and its track |
--sk-progress-bar-bg | --sk-color-accent | The filled portion |
--sk-progress-track-bg | --sk-color-border | The unfilled portion; set it to transparent for no track |
--sk-progress-radius | --sk-radius-full | End cap on the leading edge of the bar |
--sk-progress-inset-block-start | 0 | Offset from the viewport’s block-start edge, for clearing a fixed header |
z-index is not a knob. Stacking here is correctness rather than appearance
(CLAUDE.md rule 6), and the value matches tooltip and carousel. Dialogs and
popovers paint in the top layer, so they sit above the bar regardless of what
it is set to.