import * as React from 'react'; /** * "Has this scroll container moved off the top?" — as a boolean and a ref. * * Two places want it, and both want the same answer: the header, which grows an * elevation once the page has moved under it, and the rail's head, which grows a * hairline once its link list has. Both are a separator that should not be drawn * before there is anything to separate. * * ```tsx * const [scrolled, ref] = useScrolled(); *
…
*
…
* ``` * * Three things it deliberately does: * * - **A callback ref, not `useRef` + an effect.** The element these attach to is * behind a `Suspense` boundary or inside a `Sheet` that mounts and unmounts * with the drawer, so "the node exists on mount" is not true. A callback ref * fires on every attach and detach, which is exactly when the listener has to * move. * - **`passive: true`.** This handler never calls `preventDefault`, and saying so * lets the browser keep scrolling while it runs. * - **It stores a boolean, not the offset.** `setState` with an unchanged value * bails out, so a page being dragged through two thousand pixels renders * twice: once crossing the threshold, once crossing back. */ export function useScrolled( /** How far counts as "moved". A couple of pixels of rubber-banding is not. */ threshold = 4 ): [boolean, (node: T | null) => void] { const [scrolled, setScrolled] = React.useState(false); const ref = React.useCallback( (node: T | null) => { if (!node) { setScrolled(false); return; } const read = () => setScrolled(node.scrollTop > threshold); /* Once up front: a remount at a preserved scroll position (navigating back to a page the browser restores) never fires an event of its own. */ read(); node.addEventListener('scroll', read, { passive: true }); return () => node.removeEventListener('scroll', read); }, [threshold] ); return [scrolled, ref]; }