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