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…
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 one scroll listener with { passive: true } on whichever element
actually scrolls: target if target.scrollHeight > target.clientHeight,
otherwise window.
- 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 (when
present) — layout shifts like images loading change scrollHeight without
firing a scroll event.
- Clean up the scroll listener, resize listener, ResizeObserver, 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.