useScrollDirection
A scroll-direction hook that only re-renders on up/down flips, with a displacement threshold to ignore jitter.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-scroll-direction.jsonPrompt
Build a React + TypeScript "useScrollDirection" hook (no dependencies beyond
React; uses the browser scroll event + requestAnimationFrame only).
Contract
- `useScrollDirection(options?: { target?: RefObject<HTMLElement | null>;
threshold?: number }): { direction: "up" | "down" | null; scrollTop: number }`.
- `target` defaults to `window`; `threshold` defaults to `8` (pixels).
- `direction` starts `null` and only ever becomes `"up"` or `"down"` after the
first displacement past `threshold` — it never reverts to `null`.
Behavior
- Attach a single `scroll` listener (`{ passive: true }`) to `target?.current
?? window` inside an effect. Coalesce bursts of scroll events into one
measurement per animation frame with `requestAnimationFrame` — never
measure synchronously inside the scroll handler itself.
- Keep the "anchor" (last committed scroll position) in a ref, not state.
Anchor it to the target's actual scroll position at mount (not `0`), so a
page/container that's already scrolled when the hook mounts doesn't report
a phantom first direction.
- On each measured frame: compute `delta = current - anchor`. If
`Math.abs(delta) < threshold`, do nothing — leave the anchor untouched so
small same-direction increments keep accumulating toward the threshold,
while back-and-forth jitter never adds up to one.
- If `delta` clears `threshold`: advance the anchor to `current`, then compare
the resulting direction ("down" if `delta > 0`, else "up") against the
previously committed direction. Call `setState` — updating `direction` and
`scrollTop` together — only when the direction actually changed. Continuing
to scroll in the same direction keeps clearing the threshold every frame but
must not re-render on every one of those frames.
- `scrollTop` is a snapshot taken at the moment `direction` last changed, not
a continuously updated value — it must not be wired to update on every
scroll tick.
- Clean up the scroll listener and cancel any pending
`requestAnimationFrame` on unmount or when `target`/`threshold` change.
- This hook assumes `target`'s ref identity keeps pointing at the same
element for the consuming component's lifetime — it does not attempt to
detect the observed element being swapped for a different one mid-life
(unlike `scroll-progress`, direction tracking doesn't need that
robustness). Remount the consumer (change its `key`) if the observed
element itself changes.
Rendering & styling
- The hook renders nothing itself. Consumers own all UI: swap classes or a
transform based on `direction` (e.g. `direction === "down" ?
"-translate-y-full" : "translate-y-0"`), use semantic tokens (`bg-card`,
`text-primary`, `border`) for any visual feedback, and respect
`prefers-reduced-motion` (`motion-reduce:transition-none`) on any
transition built on top of `direction`.
Customization levers
- `threshold` — raise it (e.g. 40) to make direction changes noticeably
duller/steadier on twitchy trackpads or long content; lower it for a
snappier hide-on-scroll header.
- `target` — omit for whole-page scroll, or pass a ref to track a specific
scrollable container (a card, a modal body) instead of the window.
- A "top exemption zone" is deliberately NOT built in (e.g. always keep a
navbar visible while `scrollTop < 64` regardless of direction) — add it as
a small check on the consumer side (`scrollTop < 64 ? null : direction`)
rather than growing the hook's contract.
- A velocity/speed reading (px/ms) is deliberately NOT exposed — this hook
only classifies direction, not speed; derive it from consecutive
`scrollTop` snapshots and their timestamps in a wrapper hook if a consumer
needs it.Concepts
- Direction-change gating —
setStatefires only whendirectionactually flips; scrolling steadily in one direction keeps measuring every frame but re-renders the consumer exactly once, at the flip. - Displacement threshold, not time debounce — the gate is an accumulated-pixel comparison, not a timer. The anchor only advances once a delta clears
threshold, so small same-direction jitter keeps compounding toward a real flip while back-and-forth jitter never adds up to one. - Snapshot, not a live value — the
scrollTopbundled into state is captured at the instantdirectionlast changed; it is not a continuously tracked number, which is why it can't drive a progress bar or parallax effect. - rAF-coalesced measurement —
scrollcan fire far more often than the screen repaints; collapsing a burst of events down to one measurement per animation frame keeps the hook cheap even on aggressive trackpad scrolling. - Anchored to the real mount position — the anchor starts at whatever
scrollTopthe target already has when the hook mounts (not0), so a route that mounts mid-scroll doesn't report a false first direction from a phantom jump to the top.