useScroll
Track the scroll position, direction, and progress of the page or an element. This hook only reads scroll state; use native Element.scrollTo/Element.scrollBy or window.scrollTo to scroll.
API
function useScroll(options?: { target?: Element | React.RefObject<Element | null> | null }): {
x: number;
y: number;
directionX: "left" | "right" | undefined;
directionY: "up" | "down" | undefined;
progressX: number;
progressY: number;
};
Parameters:
options: Options object.options.target: The element (or its ref) whose scrolling is tracked. Defaults to the page scroll container; passnullto track nothing. A ref is read when the effect runs, so an element assigned after the first render is tracked on the next one.
Results:
x: Horizontal scroll offset. It keeps the nativescrollLeftsign, so it is negative in right-to-left layouts.y: Vertical scroll offset.directionX: The last horizontal movement direction;rightwhenxincreases andleftwhen it decreases.directionY: The last vertical movement direction;downwhenyincreases andupwhen it decreases.progressX: Horizontal scroll progress from the inline start to the inline end, from0to1.progressY: Vertical scroll progress from the top to the bottom, from0to1.
Notes
xandyare clamped to their scrollable ranges, so rubber-band overscroll does not produce out-of-range values or direction jitter.xkeeps the native sign, so it is negative in RTL.directionXanddirectionYchange only on scroll events, including programmatic scrolling. They stay unchanged when scrolling stops; while the axis remains scrollable, resize, content reflow, and bfcache restoration do not flip them. They areundefinedbefore the first movement, after the target changes, or when that axis is not scrollable.progressXaccounts for right-to-left layouts;progressYdoes not need to. Both are0when their axis is not scrollable.- Omit
targetto track the page;target: nulldisables tracking. - Updates are coalesced with
requestAnimationFrame, so there is at most one render per frame. The hook also refreshes on window or target resize and onpageshow; content that grows while the target is idle is reflected on the next scroll or resize. - Returns zeros and
undefineddirections outside the browser.
Examples
Track the page
The following example shows the page reading progress and uses native window.scrollTo to return to the top.
Progress: 0%; direction: none
Scroll the page to see the progress bar and direction update.
import { useScroll } from "@lilib/hooks";
function Example() {
const { progressY, directionY } = useScroll();
return (
<>
<div
style={{
position: "sticky",
top: 0,
zIndex: 1,
height: 8,
backgroundColor: "rgba(128, 128, 128, 0.2)",
}}
>
<div
style={{
width: `${progressY * 100}%`,
height: "100%",
backgroundColor: "orange",
}}
/>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 8 }}>
<button onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}>Back to top</button>
<span>
Progress: {Math.round(progressY * 100)}%; direction: {directionY || "none"}
</span>
</div>
<p>Scroll the page to see the progress bar and direction update.</p>
</>
);
}
export default Example;
Track an element
The following example tracks a two-dimensional scroll container; its buttons use the element's native scrollTo and scrollBy.
Scroll this box horizontally and vertically.
x: 0, y: 0, directionX: none, directionY: none, progressX: 0%, progressY: 0%
import { useRef } from "react";
import { useScroll } from "@lilib/hooks";
function Example() {
const ref = useRef<HTMLDivElement>(null);
const { x, y, directionX, directionY, progressX, progressY } = useScroll({ target: ref });
return (
<>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 8 }}>
<button onClick={() => ref.current?.scrollTo({ left: 0, top: 0, behavior: "smooth" })}>
Reset
</button>
<button onClick={() => ref.current?.scrollBy({ left: 120, behavior: "smooth" })}>
Scroll right
</button>
<button onClick={() => ref.current?.scrollBy({ top: 120, behavior: "smooth" })}>
Scroll down
</button>
</div>
<div
ref={ref}
style={{
width: 320,
height: 180,
overflow: "auto",
border: "1px solid rgba(128, 128, 128, 0.3)",
}}
>
<div
style={{
width: 900,
height: 600,
padding: 16,
background: "linear-gradient(135deg, rgba(255, 165, 0, 0.08), rgba(0, 128, 255, 0.08))",
}}
>
Scroll this box horizontally and vertically.
</div>
</div>
<div style={{ marginTop: 8, fontSize: 14 }}>
x: {Math.round(x)}, y: {Math.round(y)}, directionX: {directionX || "none"}, directionY:{" "}
{directionY || "none"}, progressX: {Math.round(progressX * 100)}%, progressY:{" "}
{Math.round(progressY * 100)}%
</div>
</>
);
}
export default Example;