scrollProgress
A Community block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call scrollProgress() with no arguments for a working demo, or edit the code below live.
Implementation notes
Single fixed-position div whose transform: scaleX(fraction) (transform-origin at the left edge) tracks scroll position. Event-driven rather than polling every frame: a passive scroll/resize listener updates a target fraction, and a requestAnimationFrame loop only runs while lerping the visible value toward that target (smoothing factor configurable), stopping once converged — reads as fluid without a perpetual per-frame cost. Supports an optional target getter for tracking a specific scrollable container instead of the whole page, as the spec's own researchNote suggested as a natural extension. Listeners/rAF are torn down on removal.
Status: ported · Reference: Magic UI original
// Magic UI "Scroll Progress" — clean-room reimplementation.
//
// A thin bar fixed to the top of the viewport that fills left-to-right in
// proportion to how far the page has scrolled. Implemented purely from the
// block's public functional/visual spec — no upstream Magic UI source was
// viewed or copied.
//
// Scroll-linked continuous effects fall outside the declarative `motion()`
// patch (which drives one-shot enter/exit/re-animate transitions, not a
// per-frame value tied to an external event), so this follows the package's
// own guidance for such cases: a `scroll`/`resize` listener wired in
// `_onMount`, torn down in `_onRemove`, driving a `requestAnimationFrame`
// loop that lerps the visible fill toward the raw scroll fraction so it
// reads as fluid rather than jittery — the loop only runs while catching up
// (rAF-debounced), not continuously.
import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
export interface ScrollProgressProps {
/** Bar thickness, in `themeSpacing` units. Defaults to `1`. */
thickness?: number;
/** Theme color family for the fill gradient. Defaults to `"primary"`. */
color?: ThemeColor;
/** Lerp factor per animation frame (0–1); higher catches up faster. Defaults to `0.2`. */
smoothing?: number;
/** Stacking order. Defaults to `50`. */
zIndex?: number;
/**
* Getter for a specific scrollable container to track instead of the
* whole page/window. Called on mount and on every scroll/resize.
*/
target?: () => Element | null;
/** Passthrough style merged onto the bar. */
style?: StyleObject;
}
/** Current scroll fraction (0–1) of `target`, or of the whole page when `target` is `window`. */
function readScrollFraction(target: Element | Window): number {
if (target === window) {
const doc = document.documentElement;
const scrolled = window.scrollY ?? doc.scrollTop;
const scrollable = doc.scrollHeight - doc.clientHeight;
return scrollable > 0 ? Math.min(1, Math.max(0, scrolled / scrollable)) : 0;
}
const element = target as HTMLElement;
const scrollable = element.scrollHeight - element.clientHeight;
return scrollable > 0 ? Math.min(1, Math.max(0, element.scrollTop / scrollable)) : 0;
}
/**
* A slim, fixed-to-top bar whose fill tracks scroll position (0 at the top
* of the page, full width at the bottom) — a passive, always-on reading
* progress indicator. Call with no arguments to track the whole page.
*/
function scrollProgress(props: ScrollProgressProps = {}): DomphyElement<"div"> {
const thickness = props.thickness ?? 1;
const color = props.color ?? "primary";
const smoothing = props.smoothing ?? 0.2;
const zIndex = props.zIndex ?? 50;
// Built through an untyped literal, then asserted, so `_doctorDisable` (a
// doctor-only annotation not present in core's strict `PartialElement`
// type) doesn't trip the excess-property check the function's declared
// return type would otherwise apply to an inline return object.
const barElement = {
div: null,
role: "progressbar",
ariaHidden: "true",
// A pure fill indicator with no text of its own — exempt from the
// missing-color contract (same idiom as other decorative-only elements
// in this package, e.g. marquee's fade overlay).
_doctorDisable: "missing-color",
_onMount: (node: ElementNode) => {
if (typeof window === "undefined") return;
const element = node.domElement as HTMLElement | null;
if (!element) return;
const getScrollTarget = (): Element | Window => props.target?.() ?? window;
let currentFraction = readScrollFraction(getScrollTarget());
let targetFraction = currentFraction;
let animating = false;
let rafHandle = 0;
const paint = () => {
element.style.transform = `scaleX(${currentFraction})`;
};
paint();
const step = () => {
currentFraction += (targetFraction - currentFraction) * smoothing;
if (Math.abs(targetFraction - currentFraction) < 0.0008) {
currentFraction = targetFraction;
paint();
animating = false;
return;
}
paint();
rafHandle = requestAnimationFrame(step);
};
const handleScroll = () => {
targetFraction = readScrollFraction(getScrollTarget());
if (!animating) {
animating = true;
rafHandle = requestAnimationFrame(step);
}
};
let listenTarget: Element | Window = getScrollTarget();
listenTarget.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleScroll, { passive: true });
node.addHook("Remove", () => {
listenTarget.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleScroll);
if (rafHandle) cancelAnimationFrame(rafHandle);
});
},
style: {
position: "fixed",
insetInlineStart: 0,
insetBlockStart: 0,
width: "100%",
height: themeSpacing(thickness),
transformOrigin: "0% 50%",
transform: "scaleX(0)",
pointerEvents: "none",
zIndex,
backgroundImage: (listener: Listener) =>
`linear-gradient(90deg, ${themeColor(listener, "shift-9", color)}, ${themeColor(listener, "shift-6", color)})`,
...(props.style ?? {}),
} as StyleObject,
} as DomphyElement<"div">;
return barElement;
}
export { scrollProgress };