Hooks

useScrollDirection

A scroll-direction hook that only re-renders on up/down flips, with a displacement threshold to ignore jitter.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export interface UseScrollDirectionOptions {
  /** Element to track instead of the window. Keep this ref's identity stable across renders — see the hook's docstring for why. */
  target?: React.RefObject<HTMLElement | null>
  /** Minimum scroll delta (px) before a direction change is committed. Default 8. */
  threshold?: number
}

export interface UseScrollDirectionResult {
  /** `null` until the first delta past `threshold` fires; then "up" or "down". */
  direction: "up" | "down" | null

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-scroll-direction.json

Prompt

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 gatingsetState fires only when direction actually 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 scrollTop bundled into state is captured at the instant direction last changed; it is not a continuously tracked number, which is why it can't drive a progress bar or parallax effect.
  • rAF-coalesced measurementscroll can 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 scrollTop the target already has when the hook mounts (not 0), so a route that mounts mid-scroll doesn't report a false first direction from a phantom jump to the top.

On This Page