Skip to content

Component

progress

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and progress.css. No scripts.

Source

Copy both files, or run npx nojsui add progress.

Source for progress
<!-- .sk-progress — a reading progress bar. Two empty elements and no script:
     the browser is already tracking how far down the document you are, and a
     scroll-progress timeline reads that number straight out of it.

     aria-hidden, and deliberately NOT role="progressbar". Without script there
     is no way to update aria-valuenow, so the role would announce a value
     frozen at zero for the life of the page — worse than announcing nothing.
     The native scrollbar already conveys scroll position to assistive
     technology; this bar is a visual echo of it, so hiding it removes nothing.

     data-sk-motion="essential" opts out of the reduced-motion clamp in
     base.css. It has to: a scroll-driven animation with its duration clamped
     to 0.01ms finishes instantly, which pins the bar at 100% and turns a
     readout into a lie. The bar produces no motion the user did not cause —
     it moves only in direct response to their own scrolling — which is what
     WCAG 2.3.3 exempts. See the README and ADR 0008.

     Position in the document does not matter; the bar is fixed and out of
     flow. First child of <body> keeps it out of everyone's way. -->
<div class="sk-progress" aria-hidden="true" data-sk-motion="essential">
  <div class="sk-progress__bar"></div>
</div>

<!-- Everything below is the demo's scrolling content, not part of the
     component. The demo runs in an <iframe srcdoc> where scroll() binds to the
     iframe's own root scroller, so this has to overflow the frame or the
     timeline is inactive and the bar never moves. -->
<article class="sk-progress__page">
  <h3 class="sk-progress__title">Scroll this panel</h3>

  <p class="sk-progress__text">
    The bar along the top edge fills as you go. There is no scroll listener
    behind it and nothing is measuring anything on the main thread — the
    animation's timeline <em>is</em> the scroll position.
  </p>

  <p class="sk-progress__text">
    Because the bar grows by its inline size rather than by a transform, it
    starts from whichever edge is the start edge. In a right-to-left document
    it fills leftward without a single extra declaration.
  </p>

  <p class="sk-progress__text">
    In Firefox there is no bar at all, rather than an empty track sitting there
    looking like a download that stalled. The scrollbar was already doing this
    job.
  </p>

  <p class="sk-progress__text">
    Under <code>prefers-reduced-motion: reduce</code> the bar keeps tracking.
    Freezing it would leave it claiming you had read everything before you had
    read anything.
  </p>

  <p class="sk-progress__text">
    Keep going — the bar is nearly full.
  </p>

  <p class="sk-progress__text sk-progress__text--end">
    That is the whole component: two empty divs, one keyframe pair and a
    timeline.
  </p>
</article>
/* .sk-progress — a reading progress bar, driven by the scroll position itself.
   A scroll timeline reads the number the browser already tracks: no listener,
   no rAF, no script. See README.md; the two rules below are why this file is
   shaped the way it is.

   1. NOTHING PAINTS OUTSIDE @supports. Firefox has no scroll timelines at any
      version (support.json), and an empty track it could never fill reads as a
      stalled download. Absent beats broken.

   2. THE BAR GROWS BY INLINE SIZE, never by a transform. `scale` needs a
      physical `transform-origin` that has to be flipped by hand for RTL
      (CLAUDE.md rule 3); `inline-size` against `inset-inline-start: 0` grows
      from the start edge in any writing mode, with no special case.

   Specs:
   - animation-timeline ..... https://drafts.csswg.org/scroll-animations-1/#animation-timeline
   - scroll() ............... https://drafts.csswg.org/scroll-animations-1/#scroll-notation
   - inline-size ............ https://drafts.csswg.org/css-logical-1/#dimension-properties */

/* Public theme knobs (ADR 0011). Read, never declared, so a :root override
   still wins on proximity. Resolved out here rather than inside the feature
   query so the whole public surface is in one findable place. */
.sk-progress {
  --_thickness: var(--sk-progress-thickness, var(--sk-space-3xs));
  --_bar-bg: var(--sk-progress-bar-bg, var(--sk-color-accent));
  --_track-bg: var(--sk-progress-track-bg, var(--sk-color-border));
  --_radius: var(--sk-progress-radius, var(--sk-radius-full));
  --_inset: var(--sk-progress-inset-block-start, 0);
}

@supports (animation-timeline: scroll()) {
  .sk-progress {
    position: fixed;

    /* The inline anchor is what makes the bar grow the right way round in
       RTL — see rule 2 above. */
    inset-block-start: var(--_inset);
    inset-inline-start: 0;

    /* Logical, so a vertical writing mode gets a vertical bar. */
    inline-size: 100%;
    block-size: var(--_thickness);
    background: var(--_track-bg);

    /* Over ordinary positioned content; the top layer is above it regardless,
       so there is nothing to fight. Same value as tooltip and carousel. Not a
       knob: stacking is correctness, not appearance (CLAUDE.md rule 6). */
    z-index: 1;
  }

  .sk-progress__bar {
    /* TRAP 1, and it matters more than it looks. A scroll timeline is
       *inactive* when its container has no overflow (a short page), and an
       inactive timeline means the animation has no effect — the element
       renders from its BASE style, not from the `from` keyframe. Unset, a
       block div fills its parent: a permanently full bar on every short page.
       Caught by this component's first visual snapshot. */
    inline-size: 0;
    block-size: 100%;
    border-end-end-radius: var(--_radius);
    border-start-end-radius: var(--_radius);
    background: var(--_bar-bg);

    /* TRAP 2: longhands, never the `animation` shorthand. It resets
       animation-duration to 0s, and a scroll-driven animation with zero
       duration finishes instantly — full bar, never moves. `auto` hands the
       timing to the timeline. reveal.css documents the same trap. */
    animation-name: sk-progress-fill;
    animation-duration: auto;
    animation-timing-function: linear;
    animation-fill-mode: both;

    /* Bare scroll() is the nearest ancestor scroll container, block axis —
       the document here, or the iframe's root scroller in the docs demo. */
    animation-timeline: scroll();
  }

  /* Deliberately NOT wrapped in prefers-reduced-motion: no-preference, which
     every other component wraps. Clamped to 0.01ms by base.css this finishes
     instantly and pins the bar at 100% — the accommodation would make the
     readout lie. The bar only ever moves in direct response to the user's own
     scrolling, the case WCAG 2.3.3 exempts; data-sk-motion="essential" in the
     markup is what opts out. ADR 0008 and the README carry the argument. */
  @keyframes sk-progress-fill {
    from {
      inline-size: 0%;
    }

    to {
      inline-size: 100%;
    }
  }
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour

   Everything below is the demo's scrolling article. The component is the two
   rules above; delete these when copying .sk-progress into your own page.
--------------------------------------------------------------------------- */

.sk-progress__page {
  display: flex;
  flex-direction: column;
  gap: var(--sk-space-md);
  padding-block: var(--sk-space-xl) var(--sk-space-3xl);
  padding-inline: var(--sk-space-md);

  /* Guarantees the demo overflows its frame at any viewport, so the timeline
     is always active. Prose alone did not: at the snapshot harness's 1280x720
     it stopped short of the fold and the demo silently stopped demonstrating
     anything. */
  min-block-size: 160vb;
}

.sk-progress__title {
  font-size: var(--sk-text-lg);
  letter-spacing: var(--sk-tracking-tight);
}

.sk-progress__text {
  max-inline-size: 34rem;
  color: var(--sk-color-text-muted);
  font-size: var(--sk-text-sm);
}

.sk-progress__text--end {
  color: var(--sk-color-text);
}

Browser support

Limited availability

Not yet in every major engine. The @supports fallback is what most visitors will get.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Scroll-driven animationslimited1151152611526

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 11 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.

KeyBehaviour
TabPasses over the bar; it is never a tab stop
Any other keyNo 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.css sets animation-duration: 0.01ms under reduce. 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 usedBaseline statusBehavior without it
animation-timeline: scroll()limited — Chrome/Edge 115+, Safari 26+, no FirefoxNothing 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

PropertyDefaultControls
--sk-progress-thickness--sk-space-3xsThickness of the bar and its track
--sk-progress-bar-bg--sk-color-accentThe filled portion
--sk-progress-track-bg--sk-color-borderThe unfilled portion; set it to transparent for no track
--sk-progress-radius--sk-radius-fullEnd cap on the leading edge of the bar
--sk-progress-inset-block-start0Offset 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.