import { useEffect, useMemo, useRef, useState } from 'react';
import { useStableCallback } from './use-stable-callback';
type UseScrollPositionOptions = {
/**
* Debounce delay in ms for scroll event handling.
* @default 32
*/
debounceDelay?: number;
/**
* Enable or disable the hook entirely.
* @default true
*/
isEnabled?: boolean;
};
/**
* Hook to track the scroll position of an element.
*
* The scroll position can be 'top', 'bottom', 'middle', or undefined if the content is not scrollable.
* The hook provides a `scrollRef` callback to assign to the scrollable element.
*
* Note that the scroll position is only updated when the user stops scrolling
* for a short moment (debounced). You can set `debounceDelay` to `0` to disable debouncing entirely.
*
* @param options Configuration options for the hook.
* @returns An object containing the `scrollRef` callback and the current `scrollPosition`.
*
* @example
* ```tsx
* import { useScrollPosition } from 'react-modal-sheet';
*
* function MyComponent() {
* const { scrollRef, scrollPosition } = useScrollPosition();
*
* return (
*
*
Scroll position: {scrollPosition}
*
* ...long content...
*
*
* );
* }
* ```
*/
export function useScrollPosition(options: UseScrollPositionOptions = {}) {
const { debounceDelay = 32, isEnabled = true } = options;
const scrollTimeoutRef = useRef(null);
const [element, setElement] = useState(null);
const [scrollPosition, setScrollPosition] = useState<
'top' | 'bottom' | 'middle' | undefined
>(undefined);
const scrollRef = useMemo(
() => (element: HTMLElement | null) => {
setElement(element);
},
[]
);
const determineScrollPosition = useStableCallback((element: HTMLElement) => {
const { scrollTop, scrollHeight, clientHeight } = element;
const isScrollable = scrollHeight > clientHeight;
if (!isScrollable) {
// Reset scroll position if the content is not scrollable anymore
if (scrollPosition) setScrollPosition(undefined);
return;
}
const isAtTop = scrollTop <= 0;
const isAtBottom =
Math.ceil(scrollHeight) - Math.ceil(scrollTop) ===
Math.ceil(clientHeight);
let position: 'top' | 'bottom' | 'middle';
if (isAtTop) {
position = 'top';
} else if (isAtBottom) {
position = 'bottom';
} else {
position = 'middle';
}
// Let browser only handle downward pan gestures (scrolling content up)
element.style.touchAction = isAtTop ? 'pan-down' : '';
if (position === scrollPosition) return;
setScrollPosition(position);
});
const onScroll = useStableCallback((event: Event) => {
if (event.currentTarget instanceof HTMLElement) {
const el = event.currentTarget;
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
if (debounceDelay === 0) {
determineScrollPosition(el);
} else {
// Debounce the scroll handler
scrollTimeoutRef.current = setTimeout(
() => determineScrollPosition(el),
debounceDelay
);
}
}
});
const onTouchStart = useStableCallback((event: Event) => {
if (event.currentTarget instanceof HTMLElement) {
const element = event.currentTarget;
requestAnimationFrame(() => {
determineScrollPosition(element);
});
}
});
useEffect(() => {
if (!element || !isEnabled) return;
// Determine initial scroll position
determineScrollPosition(element);
element.addEventListener('scroll', onScroll);
element.addEventListener('touchstart', onTouchStart);
return () => {
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
element.removeEventListener('scroll', onScroll);
element.removeEventListener('touchstart', onTouchStart);
};
}, [element, isEnabled]);
return {
scrollRef,
scrollPosition,
};
}