Scroll Progress
A thin fixed bar that tracks page or container scroll position by writing transform directly to the DOM — no React state, no re-renders.
Loading preview…
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/scroll-progress.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Scroll Progress" component. No
external dependencies beyond React and Tailwind.
Contract
- Export a forwardRef<HTMLDivElement, ScrollProgressProps> extending
React.HTMLAttributes<HTMLDivElement>.
- Props: target?: React.RefObject<HTMLElement | null> (the scroll container
or article element to track; omit to track the whole page via window);
position?: "top" | "bottom" (default "top"); height?: number in px
(default 3); className merged last via cn().
Behavior
- Attach { passive: true } scroll listeners on both target and window (just
window when there is no target), and let readProgress decide per tick which
one is scrolling. Picking one at mount would freeze that choice: a target
with no content yet only becomes scrollable later (its own scroll event),
while a static block is passed by the page (window's scroll event).
- Throttle with requestAnimationFrame: a scroll handler schedules at most
one pending rAF, coalescing bursts of scroll events into one calculation
per frame instead of one per event.
- Compute progress as a 0–1 ratio with three cases: (1) no target — document
scrollTop / (scrollHeight - clientHeight); (2) target scrolls itself —
el.scrollTop / (el.scrollHeight - el.clientHeight); (3) target is a static
block read through the page's own scroll (e.g. an <article> that doesn't
overflow) — (window.innerHeight - el.getBoundingClientRect().top) /
el.offsetHeight, clamped to [0, 1].
- On every tick, write the result straight onto the bar's DOM node —
bar.style.transform = `scaleX(${progress})` — via a merged callback ref,
never through React state. This is the component's whole point: a
high-frequency event (scroll) produces zero React re-renders.
- Paint once immediately on mount (the page or container may already be
mid-scroll when the component appears).
- Recompute on window resize and on a ResizeObserver watching target and its
direct children (when target is present) — layout shifts like images
loading change scrollHeight without firing a scroll event, and a
height-capped scroll container keeps its own box while its content grows.
A MutationObserver (childList) on target adds children appended later to
the ResizeObserver, drops removed ones, and recomputes.
- Clean up the scroll listeners, resize listener, ResizeObserver,
MutationObserver, and any pending rAF on unmount.
- Only touch window/document inside the effect — the component is safe to
render on the server.
Rendering & styling
- Single element: fixed inset-x-0 z-50, top-0 or bottom-0 per `position`,
bg-primary, origin-left (so scaleX grows left to right), height set via
inline style since it's an arbitrary pixel value. pointer-events-none and
aria-hidden — it's a supplementary visual cue, not an operable control;
the native scrollbar remains the real affordance.
- No separate track element: the unfilled portion is simply whatever is
behind the bar, so there's nothing to color — "track" is transparent by
construction.
- cn() merges className last, so consumers can override the fill color
(swap bg-primary for any bg-* token) or the position utility itself
(fixed → absolute, to pin the bar inside a card instead of the viewport).
Customization levers
- Whole-page mode: mount once with no target near the root layout — fixed
positioning already covers the full viewport width.
- Embedding in a container: pass a target ref to a scrollable element, and
override className to `absolute inset-x-0 top-0` so the bar pins to that
container's top instead of the viewport's.
- Color: className="bg-chart-2" (or any semantic token) — no prop needed.
- Thickness: the height prop; pair larger values (6–8px) with
className="rounded-full" for a pill-shaped end.
- Gradient fill: className="bg-gradient-to-r from-primary to-chart-2".
- Reduced motion: deliberately not suppressed here — this bar communicates
position, not decorative motion, so prefers-reduced-motion does not hide
or freeze it (unlike a shimmer or entrance animation).Concepts
- rAF-throttled scroll listener — the passive scroll handler doesn't compute anything itself; it just schedules a single
requestAnimationFrameif one isn't already pending, so a burst of scroll events collapses into at most one calculation per frame. - Direct DOM write, zero re-render — the computed ratio is written straight to
bar.style.transformthrough a ref, never throughuseState. Scroll is one of the highest-frequency events a page produces; routing it through React state would re-render on every tick for no visual benefitscaleXdoesn't already provide. - Three read-ratio models, one target prop — omitting
targettracks the whole page; passing a ref to a container that scrolls itself (overflow-y-auto) reads its ownscrollTop; passing a ref to a static block (like an<article>inside a normally-scrolling page) instead measures how far its bounding box has traveled through the viewport. - Transparent track — there's no dedicated "track" element to style; the bar is the only thing painted, so the unfilled portion is just whatever sits behind it.
- Position is not decoration —
prefers-reduced-motionis intentionally left unhandled here. The bar's width encodes real scroll position, not a decorative flourish, so it should keep tracking exactly as motion-sensitive users scroll, unlike an entrance or shimmer animation that reduced-motion should suppress.
Callout
An inline highlight box for docs, forms and explanatory copy — info, success, warning and danger variants with a tinted accent bar and default icon per variant.
Typing Indicator
A chat presence indicator — optional avatar, a receiver-side bubble of waving dots, and a name line that collapses for groups.