{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-progress",
  "title": "Scroll Progress",
  "description": "A bar that fills as the reader scrolls — the reading indicator across the top of an article, and the \"how much is left?\" cue on anything long. Use it on blog posts and long-form articles, documentation pages, guides and tutorials, changelogs and release notes, terms / privacy / policy pages, onboarding and multi-section landing pages, reports, and long forms or checkout flows where the reader wants to know how much further there is to go. Common asks it answers: \"reading progress bar\", \"scroll progress bar react\", \"scroll indicator component\", \"article reading progress\", \"page scroll percentage\", \"Medium-style progress bar\", \"progress bar at top of page on scroll\", \"how far down the page has the user scrolled\", \"scroll-linked progress indicator\", \"blog reading indicator\", \"useScrollProgress hook\", \"track scroll position in React\". Drop in `<ScrollProgress className=\"fixed inset-x-0 top-0 z-50\" />` for the classic placement, or render it as an ordinary block under a sticky header. Pass `target={articleRef}` when progress should mean \"through this article\" rather than \"down this page\" — on a page that continues into related posts, a comment thread or a tall footer, a whole-page bar is still short of the end when the article has actually been read, and a tracked element fills exactly as the last line arrives. `indicatorClassName` styles the filled part; the track and fill use your `--muted` and `--primary` tokens, so both themes follow automatically with no hardcoded colours. It settles the details a hand-rolled version gets wrong. Measurement is throttled to one requestAnimationFrame per scroll burst and quantised before it reaches state, so a flick that moves the bar by less than a fifth of a pixel re-renders nothing. Content that grows after first paint — an image finishing decoding, a lazily loaded section, an accordion opening, a web font swapping in — is picked up through a ResizeObserver and `document.fonts.ready`, where a scroll-and-resize-only implementation keeps reporting the old page height. A tracked element inside an app shell that scrolls its own `<main>` instead of the window is measured against that scroller, not the viewport, which is the layout where a naive bar sits frozen. When the content already fits on screen the bar reads full rather than empty, because everything there is to read is visible — the usual choice of 0 leaves a permanently empty bar on every short page, which looks broken rather than finished. The first paint is server-safe: it renders an empty bar on the server and takes its real measurement in a layout effect before the browser paints, so there is no hydration mismatch and no visible jump on a page restored mid-scroll. Decorative by design — the scrollbar already tells assistive technology where the reader is, and a `role=\"progressbar\"` updating every frame of a scroll is announced as a stream of numbers over whatever is being read, so the element is `aria-hidden` instead of noisy. `useScrollProgress` is exported for indicators this component does not draw (a percentage in the header, a circular ring, chapter markers) so they share one number instead of a second implementation that disagrees at the edges. No dependencies beyond React. Official shadcn/ui has nothing scroll-aware: its progress is a Radix bar you drive with a value you already have, not one derived from the reader's position.",
  "files": [
    {
      "path": "registry/ui/scroll-progress.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface ScrollProgressState {\n  /**\n   * How far through the tracked content the reader has got, from 0 to 1.\n   *\n   * When there is nothing to scroll this is 1, not 0: everything there is to read is\n   * already on screen, so the reader has reached the end of it. Reporting 0 there is the\n   * more common choice and it is the wrong one — it leaves a permanently empty bar on\n   * every short page, which reads as a broken component rather than a finished article.\n   * Use `scrollable` to hide the indicator instead, if that suits the design better.\n   */\n  progress: number\n  /**\n   * Whether the tracked content is longer than the viewport. False before the first\n   * measurement too, since nothing is known about the page until then.\n   */\n  scrollable: boolean\n}\n\nexport interface ScrollProgressProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /**\n   * The element to track, when progress should mean \"through this article\" rather than\n   * \"down this page\". Without it the whole scrolling page is tracked, which is the usual\n   * choice — but on a page that continues into related posts, a comment thread or a tall\n   * footer, a whole-page bar is still short of the end when the article has been read.\n   * Point this at the article and the bar fills exactly as the last line arrives.\n   *\n   * This is also the answer for an app shell whose inner `<main>` scrolls while the window\n   * does not: the page itself never moves there, so only a tracked element gives the bar\n   * something to follow.\n   */\n  target?: React.RefObject<HTMLElement | null>\n  /** Classes for the filled part, e.g. \"bg-emerald-500\" or \"rounded-r-full\". */\n  indicatorClassName?: string\n}\n\n// useLayoutEffect resolves the first measurement before paint, so the bar is never briefly\n// empty on a page restored mid-scroll; it warns during SSR, so fall back on the server.\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\n// The server cannot know a scroll position, and the first client render has to match what it\n// sent. An empty bar is the honest starting point — and the layout effect above replaces it\n// before anything is painted.\nconst INITIAL: ScrollProgressState = { progress: 0, scrollable: false }\n\n/**\n * The scrollable ancestor of a tracked element, or null when the page itself is what moves.\n *\n * An app shell that scrolls an inner `<main>` instead of the window is common in docs sites\n * and dashboards — exactly where a reading indicator goes — and measuring a tracked element\n * against the window would leave the bar frozen in precisely those layouts. This is only\n * consulted for a `target`: without one there is no element to walk up from, which is why\n * whole-page mode reports on the page and an app shell has to name its article.\n */\nfunction findScroller(el: Element | null): Element | null {\n  for (let node = el?.parentElement ?? null; node; node = node.parentElement) {\n    const overflowY = getComputedStyle(node).overflowY\n    if (\n      (overflowY === \"auto\" || overflowY === \"scroll\") &&\n      node.scrollHeight > node.clientHeight\n    ) {\n      return node\n    }\n  }\n  return null\n}\n\nfunction clamp01(n: number): number {\n  // NaN fails both comparisons and would otherwise escape as a width of \"NaN%\". It only\n  // arises from a zero-sized measurement, where 0 is the right answer anyway.\n  if (!Number.isFinite(n)) return 0\n  return n < 0 ? 0 : n > 1 ? 1 : n\n}\n\n/**\n * Progress is quantised before it reaches state, so a scroll that moves the bar by less than\n * a hundredth of a percent — under a fifth of a pixel on a 1920px screen — does not re-render\n * the tree that consumes this hook.\n */\nfunction quantise(n: number): number {\n  return Math.round(n * 10000) / 10000\n}\n\nfunction read(targetEl: Element | null, scroller: Element | null): ScrollProgressState {\n  const viewport = scroller ?? document.scrollingElement ?? document.documentElement\n  const viewportHeight = viewport.clientHeight\n\n  if (targetEl) {\n    const rect = targetEl.getBoundingClientRect()\n    // Distances are measured from the top of whatever is scrolling. For the page that is the\n    // viewport; for a nested scroller it is that box, which may sit well down the screen.\n    const top = scroller ? rect.top - scroller.getBoundingClientRect().top : rect.top\n    // The element starts being read when its top reaches the top of the viewport and is\n    // finished when its bottom reaches the bottom, so this is the distance between those.\n    const travel = rect.height - viewportHeight\n    if (travel <= 0) return { progress: 1, scrollable: false }\n    return { progress: quantise(clamp01(-top / travel)), scrollable: true }\n  }\n\n  const travel = viewport.scrollHeight - viewportHeight\n  if (travel <= 0) return { progress: 1, scrollable: false }\n  return { progress: quantise(clamp01(viewport.scrollTop / travel)), scrollable: true }\n}\n\n/**\n * How far the reader has scrolled through the page, or through `target`, as a number from\n * 0 to 1. Exported for indicators this component does not draw — a percentage in a header,\n * a circular ring, a chapter marker — so they read the same number rather than a second\n * implementation of it that disagrees at the edges.\n */\nexport function useScrollProgress(\n  target?: React.RefObject<HTMLElement | null>\n): ScrollProgressState {\n  const [state, setState] = React.useState<ScrollProgressState>(INITIAL)\n\n  // Walking the ancestors with getComputedStyle is far too much to redo on every frame of a\n  // scroll, so the answer is kept until the element it was derived from is replaced.\n  const scrollerRef = React.useRef<Element | null>(null)\n  const scrollerSourceRef = React.useRef<Element | null>(null)\n\n  const measure = React.useCallback(() => {\n    const targetEl = target?.current ?? null\n    if (targetEl !== scrollerSourceRef.current) {\n      scrollerSourceRef.current = targetEl\n      scrollerRef.current = findScroller(targetEl)\n    }\n    const next = read(targetEl, scrollerRef.current)\n    // Scrolling fires far more often than the bar changes — a trackpad flick at the top of a\n    // long page moves it by nothing at all for the first frames. Returning the previous\n    // object keeps React from re-rendering for a value that did not move.\n    setState((prev) =>\n      prev.progress === next.progress && prev.scrollable === next.scrollable ? prev : next\n    )\n  }, [target])\n\n  useIsomorphicLayoutEffect(measure, [measure])\n\n  React.useEffect(() => {\n    let frame: number | null = null\n    const schedule = () => {\n      if (frame !== null) return\n      frame = requestAnimationFrame(() => {\n        frame = null\n        measure()\n      })\n    }\n\n    // Capture, because a scroll event does not bubble: one listener on the window then covers\n    // the page and any nested scroller inside it. Passive, because this never calls\n    // preventDefault and saying so keeps it off the critical path of the scroll itself.\n    const scrollOptions = { capture: true, passive: true } as const\n    window.addEventListener(\"scroll\", schedule, scrollOptions)\n    window.addEventListener(\"resize\", schedule)\n\n    // Content that grows after first paint — an image finishing decoding, a lazily loaded\n    // section, an accordion opening — changes how far there is left to scroll without firing\n    // either of the events above, and the bar would keep reporting the old total.\n    let observer: ResizeObserver | null = null\n    if (typeof ResizeObserver !== \"undefined\") {\n      observer = new ResizeObserver(schedule)\n      observer.observe(document.documentElement)\n      const targetEl = target?.current\n      if (targetEl) observer.observe(targetEl)\n    }\n\n    // A web font swapping in reflows the text without resizing the root element.\n    let cancelled = false\n    if (document.fonts) {\n      document.fonts.ready.then(() => {\n        if (!cancelled) measure()\n      })\n    }\n\n    return () => {\n      cancelled = true\n      window.removeEventListener(\"scroll\", schedule, scrollOptions)\n      window.removeEventListener(\"resize\", schedule)\n      observer?.disconnect()\n      if (frame !== null) cancelAnimationFrame(frame)\n    }\n  }, [measure, target])\n\n  return state\n}\n\n/**\n * A bar that fills as the reader scrolls — the reading indicator across the top of an\n * article. Give it `className=\"fixed inset-x-0 top-0 z-50\"` for that placement, or drop it\n * under a sticky header as an ordinary block.\n *\n * Decorative on purpose. The scrollbar already tells assistive technology where the reader is,\n * and a `role=\"progressbar\"` whose value changes on every frame of a scroll is announced as a\n * stream of numbers over whatever is being read. Screen reader users lose nothing here, so the\n * element is hidden from them rather than made noisy — spread `aria-hidden={false}` with a role\n * and value of your own if your case genuinely differs.\n */\nexport const ScrollProgress = React.forwardRef<HTMLDivElement, ScrollProgressProps>(\n  function ScrollProgress({ className, target, indicatorClassName, ...props }, ref) {\n    const { progress } = useScrollProgress(target)\n\n    return (\n      <div\n        ref={ref}\n        aria-hidden=\"true\"\n        className={cn(\"relative h-1 w-full overflow-hidden bg-muted\", className)}\n        {...props}\n      >\n        {/*\n          Width rather than a scaleX transform: a transform would stretch any radius the\n          caller puts on this element, and the fill is positioned absolutely, so resizing it\n          lays out nothing but itself.\n\n          No transition, either. The width is already following the scroll frame by frame, and\n          animating a value that changes every frame only makes the bar lag behind the page it\n          is reporting on.\n        */}\n        <div\n          className={cn(\"absolute inset-y-0 left-0 bg-primary\", indicatorClassName)}\n          style={{ width: `${progress * 100}%` }}\n        />\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}