'use client'; /** * useScroll — reactive snapshot of `scrollX` / `scrollY` for window or any * scrollable element. * * WHY: * The DOM exposes scroll position via mutating `Element.scrollTop` / * `Element.scrollLeft`, with the only signal being a flood of `scroll` * events. To bridge this to React safely we: * * 1. **`useSyncExternalStore`** — the React-19-blessed primitive for * external mutable sources. Solves SSR (`getServerSnapshot` returns * zeros) and concurrent-mode tearing for free. Existing libraries * (`react-use`, `usehooks-ts`, `@uidotdev/usehooks`) all predate * this API and use plain `useState` + `useEffect`, which costs * them re-renders on every frame at rest if their equality bailout * is wrong (see react-use issue #1473). * * 2. **rAF-throttled writes, not subscribes** — scroll fires up to * ~120 events/s on a Magic Trackpad. Reading `scrollX` is cheap * but waking React isn't. We coalesce to one snapshot per frame * per target, then notify subscribers once. * * 3. **Module-level store keyed by EventTarget** — many components * on a page subscribe to the same `window` scroll. Without a * shared store you'd attach N listeners and run rAF N times per * frame. The store deduplicates: one listener + one rAF per * unique target, regardless of subscriber count. * * 4. **`{ passive: true }`** — non-passive scroll listeners block * compositor scrolling on mobile (Chrome warns about this in * DevTools). Always opt in. * * @example * const { y, direction, isScrolling } = useScroll(); *
* * @example * // Scroll inside a panel: * const ref = useRef(null); * const { y } = useScrollPosition(ref); * * @example * // Subscribe to ONE field — re-renders only when direction flips, * // not on every pixel of scroll: * const direction = useScrollDirection(); */ import { useCallback, useSyncExternalStore, type RefObject } from 'react'; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export type ScrollDirection = 'up' | 'down' | 'left' | 'right' | null; /** Frozen snapshot returned to React. Identity changes only when contents change. */ export interface ScrollSnapshot { /** Horizontal scroll offset in CSS pixels. */ x: number; /** Vertical scroll offset in CSS pixels. */ y: number; /** * Direction of the LAST delta. Resets to `null` after `isScrolling` * falls to `false`. For cumulative direction (e.g. hide-on-scroll * navbar), build on top of this. */ direction: ScrollDirection; /** True until 150 ms have passed since the last scroll event. */ isScrolling: boolean; } export type ScrollTarget = RefObject | Window; // ───────────────────────────────────────────────────────────────────────────── // SSR snapshot // ───────────────────────────────────────────────────────────────────────────── const SSR_SNAPSHOT: ScrollSnapshot = Object.freeze({ x: 0, y: 0, direction: null, isScrolling: false, }); // ───────────────────────────────────────────────────────────────────────────── // Per-target store // ───────────────────────────────────────────────────────────────────────────── interface TargetState { snapshot: ScrollSnapshot; subscribers: Set<() => void>; rafId: number | null; idleTimer: ReturnType | null; removeListener: () => void; } /** * One entry per unique scroll source on the page (typically just `window`, * plus any scrollable panels). Lives at module scope so every consumer * shares the same listener + rAF. */ const stores = new WeakMap(); const IDLE_MS = 150; /** Reads the current scroll offsets from a target. */ function readScroll(target: EventTarget): { x: number; y: number } { if (target === window) { return { x: window.scrollX, y: window.scrollY }; } const el = target as Element; return { x: el.scrollLeft, y: el.scrollTop }; } function deriveDirection( prev: { x: number; y: number }, next: { x: number; y: number } ): ScrollDirection { const dx = next.x - prev.x; const dy = next.y - prev.y; // Vertical wins on ties — most pages scroll vertically. if (Math.abs(dy) >= Math.abs(dx)) { if (dy > 0) return 'down'; if (dy < 0) return 'up'; return null; } if (dx > 0) return 'right'; if (dx < 0) return 'left'; return null; } function getOrCreateStore(target: EventTarget): TargetState { const existing = stores.get(target); if (existing) return existing; // Initial read so the first snapshot is correct (don't wait for first event). const initial = readScroll(target); const state: TargetState = { snapshot: Object.freeze({ x: initial.x, y: initial.y, direction: null, isScrolling: false, }), subscribers: new Set(), rafId: null, idleTimer: null, removeListener: () => {}, }; const onScroll = () => { // Coalesce bursts of scroll events to one rAF tick. Reading scrollX // mid-event is cheap, but notifying React isn't. if (state.rafId !== null) return; state.rafId = requestAnimationFrame(() => { state.rafId = null; const next = readScroll(target); const direction = deriveDirection(state.snapshot, next); // Equality bailout: if nothing changed AND we're already marked as // scrolling, skip the snapshot mint and the React notification. // This catches duplicate scroll events on the same frame. if ( next.x === state.snapshot.x && next.y === state.snapshot.y && state.snapshot.isScrolling ) { return; } state.snapshot = Object.freeze({ x: next.x, y: next.y, direction, isScrolling: true, }); // Reset idle timer — fires once user pauses for IDLE_MS. if (state.idleTimer !== null) clearTimeout(state.idleTimer); state.idleTimer = setTimeout(() => { state.idleTimer = null; state.snapshot = Object.freeze({ x: state.snapshot.x, y: state.snapshot.y, // Direction also resets — fresh scroll restarts the signal. direction: null, isScrolling: false, }); for (const cb of state.subscribers) cb(); }, IDLE_MS); for (const cb of state.subscribers) cb(); }); }; target.addEventListener('scroll', onScroll, { passive: true, capture: false }); state.removeListener = () => { target.removeEventListener('scroll', onScroll, { capture: false }); }; stores.set(target, state); return state; } function teardownStore(target: EventTarget): void { const state = stores.get(target); if (!state || state.subscribers.size > 0) return; state.removeListener(); if (state.rafId !== null) cancelAnimationFrame(state.rafId); if (state.idleTimer !== null) clearTimeout(state.idleTimer); stores.delete(target); } // ───────────────────────────────────────────────────────────────────────────── // Subscribe / getSnapshot factories // ───────────────────────────────────────────────────────────────────────────── /** * Resolves a `RefObject | Window | undefined` to an actual EventTarget at * call time. Must be called inside subscribe/getSnapshot — if the ref's * `.current` flips between renders, we want to follow it. */ function resolveTarget(target: ScrollTarget | undefined): EventTarget | null { if (typeof window === 'undefined') return null; if (!target || target === window) return window; // RefObject — read current at call site, may be null before mount. return (target as RefObject).current ?? null; } function subscribeFactory(target: ScrollTarget | undefined) { return (onChange: () => void): (() => void) => { const t = resolveTarget(target); if (!t) return () => {}; const state = getOrCreateStore(t); state.subscribers.add(onChange); return () => { state.subscribers.delete(onChange); teardownStore(t); }; }; } function getSnapshotFactory(target: ScrollTarget | undefined) { return (): ScrollSnapshot => { const t = resolveTarget(target); if (!t) return SSR_SNAPSHOT; const state = stores.get(t); return state ? state.snapshot : SSR_SNAPSHOT; }; } const getServerSnapshot = (): ScrollSnapshot => SSR_SNAPSHOT; // ───────────────────────────────────────────────────────────────────────────── // Public hooks // ───────────────────────────────────────────────────────────────────────────── /** * Reactive snapshot of scroll position + direction + isScrolling for the * given target (default: `window`). Returns a stable object reference until * something actually changes — safe to put in deps arrays. * * If you only need ONE field, prefer `useScrollPosition`, * `useScrollDirection`, or `useIsScrolling` — they subscribe to the same * underlying store but let React skip re-renders when other fields change. */ export function useScroll(target?: ScrollTarget): ScrollSnapshot { const subscribe = useCallback(subscribeFactory(target), [target]); const getSnapshot = useCallback(getSnapshotFactory(target), [target]); return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); } /** * Subscribe to just `{x, y}`. Re-renders only when the scroll offsets * change — direction flips and isScrolling transitions are ignored. */ export function useScrollPosition(target?: ScrollTarget): { x: number; y: number } { const subscribe = useCallback(subscribeFactory(target), [target]); const getServerXY = useCallback(() => SSR_XY, []); const getXY = useCallback(() => { const snap = getSnapshotFactory(target)(); // Cache by snapshot identity — every snapshot is frozen, so identity // change ⇔ content change. Avoids minting a new {x,y} on every read. return positionCache.get(snap) ?? cachePosition(snap); }, [target]); return useSyncExternalStore(subscribe, getXY, getServerXY); } const SSR_XY = Object.freeze({ x: 0, y: 0 }); const positionCache = new WeakMap(); function cachePosition(snap: ScrollSnapshot): { x: number; y: number } { const xy = Object.freeze({ x: snap.x, y: snap.y }); positionCache.set(snap, xy); return xy; } /** * Subscribe to just the direction signal. Re-renders only when direction * flips (e.g. user reverses scroll). */ export function useScrollDirection(target?: ScrollTarget): ScrollDirection { const subscribe = useCallback(subscribeFactory(target), [target]); const getDirection = useCallback( () => getSnapshotFactory(target)().direction, [target] ); const getServerDirection = useCallback((): ScrollDirection => null, []); return useSyncExternalStore(subscribe, getDirection, getServerDirection); } /** * `true` while the user is actively scrolling, `false` after a 150 ms * idle gap. Useful for hiding hover overlays while scrolling. */ export function useIsScrolling(target?: ScrollTarget): boolean { const subscribe = useCallback(subscribeFactory(target), [target]); const getIsScrolling = useCallback( () => getSnapshotFactory(target)().isScrolling, [target] ); const getServerIsScrolling = useCallback(() => false, []); return useSyncExternalStore(subscribe, getIsScrolling, getServerIsScrolling); }