The scroll-driven reading indicator utility for Tailwind CSS
Installation
npx shadcn@latest add @caprice/reading-indicator@caprice-ui/tailwindInstall the following dependencies:
npm install @caprice-ui/tailwindCopy and paste the following code into your project.
/**
* @utility reading-indicator
* @utility reading-indicator-*
* A scroll-driven progress indicator that expands across the top of the
* nearest scroll container as the user scrolls — handy for tracking reading
* progress on long-form content like articles or docs.
*
* - `reading-indicator` / `reading-indicator-sticky` pin via `position: sticky`.
* Works in any scroll context (page or contained) without depending on an
* ancestor establishing a containing block for fixed positioning.
* - `reading-indicator-fixed` pins via `position: fixed`. Use when the
* indicator should overlay the viewport edge-to-edge.
* - Animation runs on `animation-timeline: scroll()`, so it requires no JS.
* - Apply `h-*`, `bg-*` (and any other) Tailwind utilities to style.
*
* @example
* <div class="reading-indicator h-1 bg-primary" aria-hidden="true" />
* <div class="reading-indicator-fixed h-1 bg-primary" aria-hidden="true" />
*
* Note: requires CSS scroll-driven animations (Chromium 115+, Safari 26+;
* Firefox still ships them behind a flag). The bar's resting state is
* `scaleX(0)` and only the animation expands it, so unsupported browsers
* render nothing rather than a stuck full-width bar.
*
* @link https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline
*/
@theme inline {
--reading-indicator-fixed: fixed;
--reading-indicator-sticky: sticky;
}
@keyframes caprice-reading-indicator-expand {
to {
transform: scaleX(1);
}
}
@utility reading-indicator-* {
position: --value(--reading-indicator-*);
top: 0;
left: 0;
right: 0;
z-index: 50;
/* Resting state; browsers without scroll-driven animations show nothing */
transform: scaleX(0);
transform-origin: left center;
animation-name: caprice-reading-indicator-expand;
animation-timing-function: linear;
animation-duration: auto;
animation-timeline: scroll();
}
/* Bare alias for `reading-indicator-sticky`. Kept as a duplicate block:
`@apply reading-indicator-sticky` here trips Tailwind's circular-dependency
check, since the applied candidate shares this utility's root. */
@utility reading-indicator {
position: sticky;
top: 0;
left: 0;
right: 0;
z-index: 50;
transform: scaleX(0);
transform-origin: left center;
animation-name: caprice-reading-indicator-expand;
animation-timing-function: linear;
animation-duration: auto;
animation-timeline: scroll();
}
Usage
Import the utility into your global.css file:
@import "tailwindcss";
@import "@caprice-ui/tailwind/utility/reading-indicator";The utility exposes two positioning modes — pick whichever fits your layout. Height, color and any other styling come from regular Tailwind utilities.
Sticky (default)
reading-indicator (alias for reading-indicator-sticky) pins the bar via position: sticky. It works in any scroll context — the page itself or any contained scrollable area — without needing an ancestor to establish a containing block.
<article>
<div class="reading-indicator h-1 bg-primary" />
<h1>The quiet hum of machines</h1>
{/* … */}
</article>Fixed
reading-indicator-fixed pins the bar via position: fixed, so it overlays the viewport edge-to-edge regardless of where it sits in the DOM. Use this when you want the indicator to float above the page content.
<article>
<div class="reading-indicator-fixed h-1 bg-primary" />
<h1>The quiet hum of machines</h1>
{/* … */}
</article>Note:
position: fixedneeds the nearest ancestor establishing a containing block to be the viewport (the default) — if a parent appliestransform,filter,contain: paint, etc., the indicator will be contained by that ancestor instead. Reach forreading-indicator-stickyif that's getting in your way.
How it works
The utility relies on CSS scroll-driven animations — specifically animation-timeline: scroll(), which binds an animation's progress to the scroll position of the nearest scroll container.
Scroll-driven animations are supported in Chromium 115+ and Safari 26+; Firefox still ships them behind a flag. The utility degrades gracefully — the bar's resting state is scaleX(0), so unsupported browsers render nothing rather than a stuck full-width bar. If you need a visible fallback, gate one behind the @supports at-rule, e.g. @supports not (animation-timeline: scroll()).
Last updated on