Feedback

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export interface ScrollProgressProps extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * Scroll container or article element to track. If the element scrolls
   * itself (its content overflows), progress follows its own scrollTop; if
   * it's a static block read through the page's scroll (e.g. an <article>),
   * progress follows how much of it has passed the viewport instead.
   * Omit to track the whole page via `window`.
   */
  target?: React.RefObject<HTMLElement | null>

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/scroll-progress.json

Prompt

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 requestAnimationFrame if 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.transform through a ref, never through useState. 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 benefit scaleX doesn't already provide.
  • Three read-ratio models, one target prop — omitting target tracks the whole page; passing a ref to a container that scrolls itself (overflow-y-auto) reads its own scrollTop; 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 decorationprefers-reduced-motion is 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.

On This Page