Reading Progress
stimeo--reading-progress
Publishes how far the reader has scrolled through an article as a CSS custom property.
The stimeo--reading-progress controller measures how far the reader has scrolled *through* the element (0..1) and publishes it as the --stimeo--reading-progress custom property — on the element and on the document root, so a fixed bar anywhere in the page can consume it with pure CSS. IntersectionObserver alone cannot express this (the ratio stays constant while a tall article scrolls through the viewport), so this controller owns the scroll math, rAF-throttled, listening in the capture phase so articles inside overflow containers work too. change fires as the progress moves and complete once it reaches 1. The bar itself is the consumer's CSS; mark a decorative bar aria-hidden (a semantic progress belongs to stimeo--progress).
Scroll this page — the sticky bar above the article fills from the CSS variable alone (no consumer JS).
Paragraph 1: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 2: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 3: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 4: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 5: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 6: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 7: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 8: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 9: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 10: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 11: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 12: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 13: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
Paragraph 14: scroll on — the bar above tracks how far through this article you are, computed from the article's geometry and published as a CSS custom property.
<%# reading-progress: the article publishes its progress as a CSS custom property
on itself AND on :root; the bar here consumes it with pure CSS (no consumer
JS). Scroll the page to move the bar. %>
<div class="reading-progress-demo">
<p class="reading-progress-demo__hint"><%= t("components.reading_progress.demo.hint") %></p>
<div class="reading-progress-demo__bar" aria-hidden="true"></div>
<article class="reading-progress-demo__article" data-controller="stimeo--reading-progress">
<% 14.times do |i| %>
<p><%= t("components.reading_progress.demo.paragraph", index: i + 1) %></p>
<% end %>
</article>
</div>
/*
* Presentation-only styles for the reading-progress demo. The library maintains
* --stimeo--reading-progress (0..1); the bar's width is pure CSS from that
* variable. The bar is sticky so it stays visible while the article scrolls.
*/
.reading-progress-demo__hint {
margin: 0 0 0.75rem;
color: var(--muted);
}
.reading-progress-demo__bar {
position: sticky;
top: 0;
height: 0.375rem;
border-radius: 0.25rem;
background: var(--border);
overflow: hidden;
}
.reading-progress-demo__bar::before {
content: "";
display: block;
height: 100%;
width: calc(var(--stimeo--reading-progress, 0) * 100%);
background: var(--accent);
}
/*
* The article must be decisively taller than the viewport: progress is
* -top / (height - viewportHeight), so an article shorter than the viewport is
* binary (0 -> 1) by contract and the bar never fills gradually. Real text plus
* a vh floor keeps the demo progressive on any screen size.
*/
.reading-progress-demo__article {
margin-top: 0.75rem;
min-height: 180vh;
}
.reading-progress-demo__article p {
margin: 0 0 1.5rem;
color: var(--fg);
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--reading-progress"
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires with { progress } as the progress moves (rAF granularity). |
stimeo--reading-progress:change |
complete
|
Fires once the progress reaches 1. | stimeo--reading-progress:complete |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
--stimeo--reading-progress |
Controller element + document root (CSS custom property) | The reading progress, 0..1 (the root copy is removed on disconnect). |