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…

Registry dependencies
utils
"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 { 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 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 decoration — prefers-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