{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scroll-progress","type":"registry:ui","title":"ScrollProgress","description":"Thin reading-progress bar that fills as the page — or a target element — scrolls, driven by a zero-JS CSS scroll-timeline where supported and a throttled scroll listener everywhere else, with an optional accessible progressbar label","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["navigation"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/scroll-progress/ScrollProgress.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useState } from \"react\";\nimport type { HTMLAttributes } from \"react\";\nimport { cn } from \"../../utils\";\nimport { rafThrottle } from \"../../internals/motion/raf\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport \"./scroll-progress.css\";\n\n/**\n * Props for ScrollProgress\n */\nexport interface ScrollProgressProps extends HTMLAttributes<HTMLDivElement> {\n\t/**\n\t * An element to track instead of the document's own scroll. Setting\n\t * this always forces JS mode (see the README's Motion section) — a\n\t * CSS `scroll-timeline` can only reach an ancestor scroller, and an\n\t * arbitrary `target` is not guaranteed to be one, so tracking it always\n\t * costs a real scroll listener.\n\t */\n\ttarget?: HTMLElement | null;\n\t/**\n\t * `\"top\"` / `\"bottom\"` pin the bar to the matching viewport edge\n\t * (`position: fixed` — the reading-progress convention, and a\n\t * deliberate exception to the \"a library component never leaves\n\t * normal flow\" rule). `\"inline\"` renders it in normal flow instead, so\n\t * the caller places it — e.g. directly above the `target` it tracks.\n\t */\n\tposition?: \"top\" | \"bottom\" | \"inline\";\n\t/**\n\t * Announces the bar as `role=\"progressbar\"` with this accessible\n\t * name. Omitted (the default), the bar is `aria-hidden=\"true\"` —\n\t * decorative chrome, the same choice most reading-progress bars make.\n\t * Setting this also forces JS mode, same as `target`: a CSS-only bar\n\t * has no live number to hand `aria-valuenow`.\n\t */\n\tlabel?: string;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\nfunction clamp01(value: number): number {\n\tif (value < 0) return 0;\n\tif (value > 1) return 1;\n\treturn value;\n}\n\n/**\n * `scroller === window` reads the DOCUMENT's own scroll (the default,\n * no-`target` case); any other value is an element the caller wants\n * tracked instead. Only ever called from the JS-mode effect below — which\n * never runs during SSR — so the globals are safe to name even though\n * this module has no top-level `typeof window` guard.\n */\nfunction readProgress(scroller: Window | HTMLElement): number {\n\tlet scrollTop: number;\n\tlet scrollHeight: number;\n\tlet clientHeight: number;\n\tif (scroller instanceof HTMLElement) {\n\t\t({ scrollTop, scrollHeight, clientHeight } = scroller);\n\t} else {\n\t\tscrollTop = window.scrollY;\n\t\tscrollHeight = document.documentElement.scrollHeight;\n\t\tclientHeight = document.documentElement.clientHeight;\n\t}\n\tconst max = scrollHeight - clientHeight;\n\t// Nothing to scroll (content fits, or hasn't laid out yet under\n\t// jsdom) reads as 0, not NaN/Infinity from a division by zero — an\n\t// empty reading-progress bar is the honest answer for \"no distance to\n\t// travel\", not an undefined one.\n\treturn max > 0 ? clamp01(scrollTop / max) : 0;\n}\n\n/**\n * A thin bar that fills as the reader scrolls — the whole document by\n * default, or a specific element via `target`. On a browser that supports\n * CSS scroll-driven animations it needs no JavaScript at all; everywhere\n * else it falls back to a throttled scroll listener.\n */\nexport const ScrollProgress = forwardRef<HTMLDivElement, ScrollProgressProps>(\n\tfunction ScrollProgress(\n\t\t{ target = null, position = \"top\", label, className, ...restProps },\n\t\tforwardedRef\n\t) {\n\t\t// The JS-mode effect needs the live node (convention C-1), and the\n\t\t// consumer's ref rides along composed with it (C-2, C-4).\n\t\tconst [node, nodeRef] = useElementRef<HTMLDivElement>();\n\t\tconst composedRef = useComposedRefs(forwardedRef, nodeRef);\n\n\t\t/**\n\t\t * `CSS.supports(\"animation-timeline\", \"scroll()\")` is a fact about the\n\t\t * environment — probed exactly ONCE, in a mount effect, and never\n\t\t * again. Browser support for a CSS feature doesn't change mid-session,\n\t\t * so re-probing on every render would just be wasted work.\n\t\t *\n\t\t * `undefined` (not yet probed) is also the SSR value and the value for\n\t\t * every render before the mount effect runs — `data-mode` is therefore\n\t\t * absent from the server-rendered HTML and only ever gets ADDED\n\t\t * client-side, never changed out from under something the server\n\t\t * already committed to. That is what keeps this hydration-safe: an\n\t\t * attribute that doesn't exist yet on the server can't mismatch one\n\t\t * added later.\n\t\t */\n\t\tconst [supportsScrollTimeline, setSupportsScrollTimeline] = useState<boolean | undefined>(\n\t\t\tundefined\n\t\t);\n\n\t\t/**\n\t\t * `mode` is derived per render, NOT latched at the same moment as the\n\t\t * capability probe above — it is recomputed from\n\t\t * `supportsScrollTimeline` together with the live `target`/`label`\n\t\t * props. That distinction matters because a `target` (or `label`) can\n\t\t * arrive AFTER mount: a callback-ref element resolved on a later\n\t\t * render, a conditionally rendered scroller, async content, or a route\n\t\t * change can hand this component a target after the fact. Deriving\n\t\t * `mode` means a bar that starts in CSS mode still flips to JS mode\n\t\t * the instant such a `target`/`label` shows up, instead of staying\n\t\t * latched onto the document's scroll forever. See the README's\n\t\t * Implementation notes.\n\t\t */\n\t\tconst mode: \"css\" | \"js\" | undefined =\n\t\t\tsupportsScrollTimeline === undefined\n\t\t\t\t? undefined\n\t\t\t\t: !target && label === undefined && supportsScrollTimeline\n\t\t\t\t\t? \"css\"\n\t\t\t\t\t: \"js\";\n\n\t\t/**\n\t\t * Only meaningful once `label` is set — otherwise `aria-valuenow` is\n\t\t * never rendered at all (see the JSX) and this is harmless, unread\n\t\t * state. Routed through state rather than written straight to the DOM:\n\t\t * unlike the per-frame `--ft-scrollprogress-value` write below, this\n\t\t * only changes once per throttled tick and rounds to a whole percent,\n\t\t * so the \"bypass reactivity in a hot loop\" doctrine doesn't apply —\n\t\t * there is no hot loop here, just an occasional integer.\n\t\t */\n\t\tconst [valueNow, setValueNow] = useState(0);\n\n\t\tuseEffect(() => {\n\t\t\tsetSupportsScrollTimeline(\n\t\t\t\ttypeof CSS !== \"undefined\" &&\n\t\t\t\t\ttypeof CSS.supports === \"function\" &&\n\t\t\t\t\tCSS.supports(\"animation-timeline\", \"scroll()\")\n\t\t\t);\n\t\t}, []);\n\n\t\t// Only JS mode ever attaches anything. CSS mode's entire reason to\n\t\t// exist is tracking scroll with ZERO JS — including before this effect\n\t\t// has had a chance to run at all, via the `@supports`-gated keyframe\n\t\t// animation in the colocated stylesheet.\n\t\tuseEffect(() => {\n\t\t\tif (mode !== \"js\") return;\n\t\t\tif (!node) return;\n\t\t\tconst scroller: Window | HTMLElement = target ?? window;\n\t\t\tconst eventTarget = scroller as EventTarget;\n\n\t\t\tconst update = () => {\n\t\t\t\tconst progress = readProgress(scroller);\n\t\t\t\t// Bypasses React state on purpose, per the family's rAF-loop\n\t\t\t\t// doctrine: this can run up to once per animation frame, and\n\t\t\t\t// routing it through state → re-render would be the exact\n\t\t\t\t// anti-pattern the doctrine warns about for a per-frame write.\n\t\t\t\t// Set on the ROOT (not the bar) — CSS custom properties\n\t\t\t\t// inherit, so the bar picks the value up through\n\t\t\t\t// `.ft-scrollprogress-bar`'s own\n\t\t\t\t// `scaleX(var(--ft-scrollprogress-value, 0))` without a second\n\t\t\t\t// write.\n\t\t\t\tnode.style.setProperty(\"--ft-scrollprogress-value\", String(progress));\n\t\t\t\tif (label !== undefined) setValueNow(Math.round(progress * 100));\n\t\t\t};\n\n\t\t\tconst throttled = rafThrottle(update);\n\t\t\tconst onScrollOrResize = () => throttled();\n\n\t\t\tupdate(); // paints the CURRENT position immediately, not just on the next scroll/resize\n\t\t\teventTarget.addEventListener(\"scroll\", onScrollOrResize, { passive: true });\n\n\t\t\t// `resize` is a WINDOW event: an element never fires one, so on the\n\t\t\t// `target` path that listener only ever looked like it was doing\n\t\t\t// something. What changes an element scroller's progress without a\n\t\t\t// scroll is its content growing — a streamed answer, a lazily loaded\n\t\t\t// page, an image finishing its download — and each of those leaves the\n\t\t\t// bar reporting the fraction it had while the content was shorter.\n\t\t\t//\n\t\t\t// Divergence from the Svelte source, which registers the same\n\t\t\t// never-firing `resize` listener on its target; observing is the only\n\t\t\t// way to get the event the listener was reaching for.\n\t\t\tlet observer: ResizeObserver | null = null;\n\t\t\tlet mutations: MutationObserver | null = null;\n\n\t\t\tif (!target) {\n\t\t\t\teventTarget.addEventListener(\"resize\", onScrollOrResize, { passive: true });\n\t\t\t} else {\n\t\t\t\tconst scrollerEl = target;\n\n\t\t\t\tif (typeof ResizeObserver !== \"undefined\") {\n\t\t\t\t\t// The scroller's own box says how much fits; its children's boxes\n\t\t\t\t\t// say how much there is. Both move the fraction.\n\t\t\t\t\tobserver = new ResizeObserver(onScrollOrResize);\n\t\t\t\t\tobserver.observe(scrollerEl);\n\t\t\t\t\t// One full pass at connect time; after that the rows are\n\t\t\t\t\t// tracked through the mutation records instead. `observe` is\n\t\t\t\t\t// itself a scan of the observation list, so re-walking the\n\t\t\t\t\t// whole child list on every batch would make a streamed\n\t\t\t\t\t// transcript pay a cost quadratic in its length for each\n\t\t\t\t\t// appended chunk.\n\t\t\t\t\tfor (const child of Array.from(scrollerEl.children)) observer.observe(child);\n\n\t\t\t\t\tconst observeRows = (records: MutationRecord[]) => {\n\t\t\t\t\t\tfor (const record of records) {\n\t\t\t\t\t\t\tif (record.target !== scrollerEl) continue;\n\t\t\t\t\t\t\tfor (const added of record.addedNodes) {\n\t\t\t\t\t\t\t\tif (added instanceof Element) observer?.observe(added);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tfor (const removed of record.removedNodes) {\n\t\t\t\t\t\t\t\tif (removed instanceof Element) observer?.unobserve(removed);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\n\t\t\t\t\tif (typeof MutationObserver !== \"undefined\") {\n\t\t\t\t\t\tmutations = new MutationObserver((records) => {\n\t\t\t\t\t\t\tobserveRows(records);\n\t\t\t\t\t\t\tonScrollOrResize();\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t} else if (typeof MutationObserver !== \"undefined\") {\n\t\t\t\t\tmutations = new MutationObserver(onScrollOrResize);\n\t\t\t\t}\n\n\t\t\t\t// Content arriving is a change of scroll HEIGHT with every observed\n\t\t\t\t// border box possibly identical — appended text in a fixed-size\n\t\t\t\t// child included — so the mutation is a signal of its own, not a\n\t\t\t\t// stand-in for the resize.\n\t\t\t\tmutations?.observe(scrollerEl, { childList: true, subtree: true, characterData: true });\n\t\t\t}\n\n\t\t\treturn () => {\n\t\t\t\teventTarget.removeEventListener(\"scroll\", onScrollOrResize);\n\t\t\t\teventTarget.removeEventListener(\"resize\", onScrollOrResize);\n\t\t\t\tobserver?.disconnect();\n\t\t\t\tmutations?.disconnect();\n\t\t\t\tthrottled.cancel();\n\t\t\t};\n\t\t}, [mode, node, target, label]);\n\n\t\treturn (\n\t\t\t<div\n\t\t\t\tref={composedRef}\n\t\t\t\tclassName={cn(\"ft-scrollprogress\", className)}\n\t\t\t\t{...restProps}\n\t\t\t\tdata-position={position}\n\t\t\t\tdata-mode={mode}\n\t\t\t\trole={label !== undefined ? \"progressbar\" : undefined}\n\t\t\t\taria-label={label}\n\t\t\t\taria-valuemin={label !== undefined ? 0 : undefined}\n\t\t\t\taria-valuemax={label !== undefined ? 100 : undefined}\n\t\t\t\taria-valuenow={label !== undefined ? valueNow : undefined}\n\t\t\t\taria-hidden={label === undefined ? \"true\" : undefined}\n\t\t\t>\n\t\t\t\t<div className=\"ft-scrollprogress-bar\"></div>\n\t\t\t</div>\n\t\t);\n\t}\n);\n\nScrollProgress.displayName = \"ScrollProgress\";\n","type":"registry:ui","target":"@ui/fancy/components/scroll-progress/ScrollProgress.tsx"},{"path":"react/src/components/scroll-progress/index.ts","content":"\"use client\";\n\nexport { ScrollProgress } from \"./ScrollProgress\";\nexport type { ScrollProgressProps } from \"./ScrollProgress\";\n","type":"registry:ui","target":"@ui/fancy/components/scroll-progress/index.ts"},{"path":"react/src/components/scroll-progress/scroll-progress.css","content":".ft-scrollprogress {\n\tdisplay: block;\n\theight: var(--ft-scrollprogress-thickness, 3px);\n\tbackground: var(--ft-scrollprogress-track, transparent);\n\toverflow: hidden;\n}\n\n.ft-scrollprogress[data-position=\"top\"],\n.ft-scrollprogress[data-position=\"bottom\"] {\n\tposition: fixed;\n\tright: 0;\n\tleft: 0;\n\tz-index: var(--ft-scrollprogress-z, 50);\n}\n.ft-scrollprogress[data-position=\"top\"] {\n\ttop: 0;\n}\n.ft-scrollprogress[data-position=\"bottom\"] {\n\tbottom: 0;\n}\n.ft-scrollprogress[data-position=\"inline\"] {\n\tposition: static;\n\twidth: 100%;\n}\n\n/* Logical default: the bar fills from the reading-start edge. RTL flips\n   which physical edge that is — `transform-origin` isn't itself a\n   direction-aware property, so it needs this explicit override. */\n.ft-scrollprogress:dir(rtl) {\n\t--ft-scrollprogress-origin: right;\n}\n\n.ft-scrollprogress-bar {\n\twidth: 100%;\n\theight: 100%;\n\tbackground: var(--ft-scrollprogress-color, currentColor);\n\t/* scaleX, never width — the family rule (\"opacity/transform only\")\n\t   plus a practical one: animating width forces layout on every frame,\n\t   scaleX is compositor-only. */\n\ttransform: scaleX(var(--ft-scrollprogress-value, 0));\n\ttransform-origin: var(--ft-scrollprogress-origin, left);\n\ttransition: none;\n}\n\n/*\n * The zero-JS path: a native scroll-driven animation grows the bar from\n * 0 to full width purely off the document's own scroll position, working\n * before hydration and without this component ever running a scroll\n * listener. `:not([data-mode=\"js\"])` matches BOTH the pre-mount state (no\n * `data-mode` attribute exists yet) and the post-mount `\"css\"` mode\n * explicitly. What this guarantees: no hydration mismatch (the attribute\n * is only ever ADDED client-side, never changed out from under something\n * the server already committed to), and a bar that ends up in CSS mode is\n * already painting correctly before the mount effect runs at all. It does\n * NOT guarantee the mechanism is always right pre-mount for a bar that ends\n * up in JS mode (a `target`/`label` bar): for the one frame between first\n * paint and mount, `data-mode` is absent, this rule matches, and the\n * DOCUMENT's scroll is what's actually driving the fill — normally\n * invisible, but a page restored mid-scroll or reached via an anchor can\n * paint a wrongly-filled bar for that frame before it snaps to the\n * target's own progress.\n *\n * Deliberately NOT gated behind `prefers-reduced-motion` — see the\n * README's Motion section for why a 1:1 scroll-position mapping isn't the\n * kind of animation that preference is about.\n */\n@supports (animation-timeline: scroll()) {\n\t.ft-scrollprogress:not([data-mode=\"js\"]) .ft-scrollprogress-bar {\n\t\tanimation: ft-scrollprogress-grow linear both;\n\t\tanimation-timeline: scroll(root block);\n\t\t/* The shorthand above resets `animation-duration` to its initial\n\t\t   value, which the scroll-animations spec changes from `0s` to\n\t\t   `auto` — on an engine that ships `animation-timeline` but still\n\t\t   carries the OLD initial value, that reset would jump the bar to\n\t\t   `scaleX(1)` permanently instead of tracking the timeline. Written\n\t\t   standalone (not folded into the shorthand) so that on an engine\n\t\t   that doesn't accept `auto` here, this single declaration is what\n\t\t   drops — not the whole rule. */\n\t\tanimation-duration: auto;\n\t}\n}\n@keyframes ft-scrollprogress-grow {\n\tfrom {\n\t\ttransform: scaleX(0);\n\t}\n\tto {\n\t\ttransform: scaleX(1);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/scroll-progress/scroll-progress.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/scroll-progress?framework=react"}}