Skip to content

useScroll

Tracks the scroll position, scroll direction, arrived state (top/bottom/left/right), and scrolling status of any scrollable target — HTMLElement, Document, or Window — as reactive Observable values.

x: 0y: 0idletopbottom
Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9
Item 10
Item 11
Item 12
Item 13
Item 14
Item 15
Item 16
Item 17
Item 18
Item 19
Item 20

Scroll inside the box to see x, y, arrivedState, and directions update.

import {
import useScroll
useScroll
,
function useRef$<T extends Element = Element>(externalRef?: React.Ref<T> | null): Ref$<T>

Creates an observable element ref. Can be used as a drop-in replacement for useRef, composed with callback refs, or used with forwardRef.

The element is wrapped with opaqueObject to prevent legendapp/state from making DOM properties reactive (deep observation).

@paramexternalRef - Optional. Accepts callback ref, RefObject, or null (forwardRef compatible).

@returnsA callable ref that is also observable via get/peek

@example

// standalone — useRef replacement
const el$ = useRef$<HTMLDivElement>();
return <div ref={el$} />;
// forwardRef compatible
const Component = forwardRef<HTMLDivElement>((props, ref) => {
const el$ = useRef$(ref);
return <div ref={el$} />;
});
// callback ref composition
const myRef = useCallback((node: HTMLDivElement | null) => {
node?.focus();
}, []);
const el$ = useRef$(myRef);
return <div ref={el$} />;

useRef$
} from "@usels/core";
function
function Component(): React.JSX.Element
Component
() {
const
const el$: Ref$<HTMLDivElement>
el$
=
useRef$<HTMLDivElement>(externalRef?: React.Ref<HTMLDivElement> | undefined): Ref$<HTMLDivElement>

Creates an observable element ref. Can be used as a drop-in replacement for useRef, composed with callback refs, or used with forwardRef.

The element is wrapped with opaqueObject to prevent legendapp/state from making DOM properties reactive (deep observation).

@paramexternalRef - Optional. Accepts callback ref, RefObject, or null (forwardRef compatible).

@returnsA callable ref that is also observable via get/peek

@example

// standalone — useRef replacement
const el$ = useRef$<HTMLDivElement>();
return <div ref={el$} />;
// forwardRef compatible
const Component = forwardRef<HTMLDivElement>((props, ref) => {
const el$ = useRef$(ref);
return <div ref={el$} />;
});
// callback ref composition
const myRef = useCallback((node: HTMLDivElement | null) => {
node?.focus();
}, []);
const el$ = useRef$(myRef);
return <div ref={el$} />;

useRef$
<
interface HTMLDivElement
HTMLDivElement
>();
const {
const x$: any
x$
,
const y$: any
y$
,
const arrivedState$: any
arrivedState$
} =
import useScroll
useScroll
(
const el$: Ref$<HTMLDivElement>
el$
);
return (
<
React.JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
div
React.RefAttributes<HTMLDivElement>.ref?: React.Ref<HTMLDivElement> | undefined

Allows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref).

ref
={
const el$: Ref$<HTMLDivElement>
el$
}
React.HTMLAttributes<HTMLDivElement>.style?: React.CSSProperties | undefined
style
={{
StandardShorthandProperties<string | number, string & {}>.overflow?: Property.Overflow | undefined

This feature is well established and works across many devices and browser versions. It’s been available across browsers since July 2015.

Syntax: [ visible | hidden | clip | scroll | auto ]{1,2}

Initial value: visible

| Chrome | Firefox | Safari | Edge | IE | | :----: | :-----: | :----: | :----: | :---: | | 1 | 1 | 1 | 12 | 4 |

overflow
: "auto",
StandardLonghandProperties<string | number, string & {}>.height?: Property.Height<string | number> | undefined

This feature is well established and works across many devices and browser versions. It’s been available across browsers since July 2015.

Syntax: auto | <length-percentage [0,∞]> | min-content | max-content | fit-content | fit-content(<length-percentage [0,∞]>) | <calc-size()> | <anchor-size()>

Initial value: auto

| Chrome | Firefox | Safari | Edge | IE | | :----: | :-----: | :----: | :----: | :---: | | 1 | 1 | 1 | 12 | 4 |

height
: 300 }}>
<
React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>
p
>
scrollX: {
const x$: any
x$
.
any
get
()}, scrollY: {
const y$: any
y$
.
any
get
()}
{
const arrivedState$: any
arrivedState$
.
any
bottom
.
any
get
() && " — reached bottom"}
</
React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>
p
>
</
React.JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
div
>
);
}

Use useWindowScroll for the common case, or pass window directly.

import { useScroll } from "@usels/core";
function Component() {
const { y$, arrivedState$, isScrolling$ } = useScroll(window);
}
import { useScroll, useRef$ } from "@usels/core";
function Component() {
const el$ = useRef$<HTMLDivElement>();
const { directions$ } = useScroll(el$);
// directions$.bottom.get() → true while scrolling down
// directions$.top.get() → true while scrolling up
}

Use offset to declare a threshold (in px) before the edge is considered “arrived”.

const { arrivedState$ } = useScroll(el$, {
offset: { bottom: 100 }, // bottom=true when within 100px of the end
});
const { isScrolling$ } = useScroll(el$, {
idle: 300, // ms to wait before isScrolling becomes false (default: 200)
onStop: () => {
// called when scrolling stops
},
});
const { x$, y$ } = useScroll(el$, { throttle: 50 }); // handler fires at most once per 50ms
const { y$, measure } = useScroll(el$);
// Call measure() to force-sync scroll state without a scroll event
measure();

Passing null is safe — all observables stay at their initial values and no event listener is registered.

import { useScroll } from "@usels/core";
const target = typeof window !== "undefined" ? document : null;
const { y$ } = useScroll(target);

Reactive observables, not state. All returned values (x$, y$, isScrolling$, arrivedState$, directions$) are Legend-State Observables. Read them with .get() inside a reactive context (useObserve, etc.) to avoid unnecessary re-renders.

measure() is synchronous. It immediately reads the current scroll values from the DOM and updates all observables. Useful after programmatic scroll operations.

arrivedState initial values. On mount, top and left default to true, and bottom/right default to false. After the first measure() call (triggered automatically on mount), all values are synced with actual DOM state.

export interface UseScrollOptions {
throttle?: number;
idle?: number;
onScroll?: (e: Event) => void;
onStop?: () => void;
onError?: (error: unknown) => void;
offset?: {
left?: number;
right?: number;
top?: number;
bottom?: number;
};
behavior?: ScrollBehavior;
eventListenerOptions?: MaybeObservable<AddEventListenerOptions>;
}
export interface ArrivedState {
left: boolean;
right: boolean;
top: boolean;
bottom: boolean;
}
export interface ScrollDirections {
left: boolean;
right: boolean;
top: boolean;
bottom: boolean;
}
export interface UseScrollReturn {
x$: Observable<number>;
y$: Observable<number>;
isScrolling$: Observable<boolean>;
arrivedState$: Observable<ArrivedState>;
directions$: Observable<ScrollDirections>;
measure: () => void;
}
export declare function useScroll(element: MaybeElement, options?: UseScrollOptions): UseScrollReturn;

View on GitHub

  • tigerwest
  • a7392ab 2026-03-06 - feat(core,browser): add sync strategy hooks (tigerwest)