useScrollDirection reports whether the user is currently scrolling up or down as React state, returning null until they have scrolled. The browser's scroll event only tells you the current position, so direction is a comparison against where you were a moment ago — and because raw scrolling jitters by a pixel or two, you ignore movements below a small threshold so the reported value stays stable. The classic use is a sticky header that hides on scroll-down and reappears on scroll-up.
type ScrollDirection = 'up' | 'down' | null;
function useScrollDirection(options?: { threshold?: number }): ScrollDirection;
// null before the first real scroll; then 'up' or 'down'.
// threshold: pixels of movement to ignore as jitter (default 6).
// Hide a sticky header on the way down, show it on the way up.
const direction = useScrollDirection();
const hidden = direction === 'down';
return <header className={hidden ? 'header header--hidden' : 'header'}>...</header>;
// Momentum scrolling on a trackpad is noisy — widen the dead zone.
const direction = useScrollDirection({ threshold: 12 });
window.scrollY (the vertical offset). This hook reports vertical direction only.threshold pixels so trackpad wobble and momentum drift do not flip the value. Below the threshold, keep the last reported direction.scroll listener as { passive: true } (you never call preventDefault) and remove it on unmount.null on the first render.You keep the previous scroll position in a ref, compare each new scroll against it, ignore moves smaller than a threshold, and update state only when the direction actually flips.
You want a sticky header that slides away when the reader scrolls down — getting out of their way — and drops back in when they scroll up toward the nav. To do that you need one bit of information: is the page moving down or up right now? The scroll event only hands you the current offset, never the direction, so you have to compare each event against where you were a moment ago. Two things make that trickier than it sounds: the previous position has to stay fresh, and real scrolling wobbles by a pixel or two even when the finger is holding still.
Direction is a comparison — the current position against the last one. But a bare comparison is twitchy: a trackpad reports sub-pixel wobble and momentum scrolling drifts, so the raw delta flickers between tiny positives and negatives. The fix is a threshold band around the last position. A move that stays inside the band is jitter and reports nothing; only a move that leaves the band counts as a real scroll and can flip the direction.
The obvious version keeps the last position in state and compares to it on every scroll:
function useScrollDirection() {
const [direction, setDirection] = useState(null);
const [lastY, setLastY] = useState(0);
useEffect(() => {
const onScroll = () => {
const currentY = window.scrollY;
setDirection(currentY > lastY ? 'down' : 'up');
setLastY(currentY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return direction;
}
This fails two ways. First, the effect runs once ([] deps), so onScroll closes over the lastY from the first render — 0 — and never sees an updated value. Every scroll is compared against 0, so any positive offset reads as 'down'; scrolling up from 300 to 250 still reports 'down'. Second, there is no threshold, so a one-pixel trackpad wobble flips the value and re-renders. And because it calls setLastY on every event, it re-renders on every scroll even when the direction has not changed.
const { useState, useEffect, useRef } = require('react');
function useScrollDirection(options = {}) {
const { threshold = 6 } = options;
const [direction, setDirection] = useState(null);
// The previous position lives in a ref, not state, so the handler always
// reads the latest value (no stale closure) and updating it never re-renders.
const lastY = useRef(typeof window !== 'undefined' ? window.scrollY : 0);
useEffect(() => {
const min = Math.max(0, threshold);
const onScroll = () => {
const currentY = window.scrollY;
const delta = currentY - lastY.current;
// Below the threshold it is jitter — ignore it and leave the baseline put.
if (Math.abs(delta) < min) return;
const next = delta > 0 ? 'down' : 'up';
lastY.current = currentY; // advance the baseline to the latest position
// Only re-render when the direction actually flips; same value bails out.
setDirection((prev) => (prev === next ? prev : next));
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [threshold]);
return direction;
}
module.exports = { useScrollDirection };
Three shifts from the naive version. The previous position moves from useState to a useRef, so the handler reads lastY.current live — the closure is never stale — and mutating it does not trigger a render. A threshold guard drops sub-threshold moves before they can flip anything. And setDirection gets a functional update that returns the previous value unchanged when the direction is the same, so React bails out and same-direction scrolls cause no re-render. The listener is { passive: true } and is removed on unmount.
Start at the top of the page with the default threshold of 6. On mount, lastY.current is 0 and direction is null.
+40, past the threshold, so next is 'down'. Set lastY.current = 40 and flip direction from null to 'down'. One re-render; the header hides.+50, still 'down'. Advance lastY.current = 90, but setDirection returns the same 'down', so React bails — no re-render.-2, under the threshold, so the handler returns early. The baseline stays 90; direction stays 'down'.-40 against the fresh baseline of 90, past the threshold, so next is 'up'. Set lastY.current = 50 and flip direction to 'up'. The header slides back in.Four scroll events, two state changes — only the genuine flips. Position updates on every frame; direction is a stable enum that changes rarely, which is exactly what downstream UI wants to gate on.
useState and reading it from a handler you subscribed once ([] deps) closes over the first value, so every scroll is compared against 0. Hold it in a useRef and mutate lastY.current each event.threshold, and leave the baseline unmoved so the band stays put.setState on each event (or storing the position in state) re-renders dozens of times a second even when nothing changed. Keep the position in a ref and only setDirection on a flip.window.scrollY inside the handler when it fires, not when the effect first runs.scroll listener makes the browser wait on your handler before scrolling. Pass { passive: true } since you never call preventDefault.el.scrollTop to track direction inside any scroll container, not just the window.window.scrollX yields 'left' and 'right'; some libraries report all four.useScrollLock) or react-use (which has useScroll and useWindowScroll, position only). Mantine's use-scroll-direction keeps the previous position in a ref and returns 'up' or 'down', but applies no threshold. reactuse's useScroll exposes direction as four booleans with throttle and idle timing, and ahooks' useScroll gives you the position to derive direction from.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useScrollDirection reports whether the user is currently scrolling up or down as React state, returning null until they have scrolled. The browser's scroll event only tells you the current position, so direction is a comparison against where you were a moment ago — and because raw scrolling jitters by a pixel or two, you ignore movements below a small threshold so the reported value stays stable. The classic use is a sticky header that hides on scroll-down and reappears on scroll-up.
type ScrollDirection = 'up' | 'down' | null;
function useScrollDirection(options?: { threshold?: number }): ScrollDirection;
// null before the first real scroll; then 'up' or 'down'.
// threshold: pixels of movement to ignore as jitter (default 6).
// Hide a sticky header on the way down, show it on the way up.
const direction = useScrollDirection();
const hidden = direction === 'down';
return <header className={hidden ? 'header header--hidden' : 'header'}>...</header>;
// Momentum scrolling on a trackpad is noisy — widen the dead zone.
const direction = useScrollDirection({ threshold: 12 });
window.scrollY (the vertical offset). This hook reports vertical direction only.threshold pixels so trackpad wobble and momentum drift do not flip the value. Below the threshold, keep the last reported direction.scroll listener as { passive: true } (you never call preventDefault) and remove it on unmount.null on the first render.You keep the previous scroll position in a ref, compare each new scroll against it, ignore moves smaller than a threshold, and update state only when the direction actually flips.
You want a sticky header that slides away when the reader scrolls down — getting out of their way — and drops back in when they scroll up toward the nav. To do that you need one bit of information: is the page moving down or up right now? The scroll event only hands you the current offset, never the direction, so you have to compare each event against where you were a moment ago. Two things make that trickier than it sounds: the previous position has to stay fresh, and real scrolling wobbles by a pixel or two even when the finger is holding still.
Direction is a comparison — the current position against the last one. But a bare comparison is twitchy: a trackpad reports sub-pixel wobble and momentum scrolling drifts, so the raw delta flickers between tiny positives and negatives. The fix is a threshold band around the last position. A move that stays inside the band is jitter and reports nothing; only a move that leaves the band counts as a real scroll and can flip the direction.
The obvious version keeps the last position in state and compares to it on every scroll:
function useScrollDirection() {
const [direction, setDirection] = useState(null);
const [lastY, setLastY] = useState(0);
useEffect(() => {
const onScroll = () => {
const currentY = window.scrollY;
setDirection(currentY > lastY ? 'down' : 'up');
setLastY(currentY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return direction;
}
This fails two ways. First, the effect runs once ([] deps), so onScroll closes over the lastY from the first render — 0 — and never sees an updated value. Every scroll is compared against 0, so any positive offset reads as 'down'; scrolling up from 300 to 250 still reports 'down'. Second, there is no threshold, so a one-pixel trackpad wobble flips the value and re-renders. And because it calls setLastY on every event, it re-renders on every scroll even when the direction has not changed.
const { useState, useEffect, useRef } = require('react');
function useScrollDirection(options = {}) {
const { threshold = 6 } = options;
const [direction, setDirection] = useState(null);
// The previous position lives in a ref, not state, so the handler always
// reads the latest value (no stale closure) and updating it never re-renders.
const lastY = useRef(typeof window !== 'undefined' ? window.scrollY : 0);
useEffect(() => {
const min = Math.max(0, threshold);
const onScroll = () => {
const currentY = window.scrollY;
const delta = currentY - lastY.current;
// Below the threshold it is jitter — ignore it and leave the baseline put.
if (Math.abs(delta) < min) return;
const next = delta > 0 ? 'down' : 'up';
lastY.current = currentY; // advance the baseline to the latest position
// Only re-render when the direction actually flips; same value bails out.
setDirection((prev) => (prev === next ? prev : next));
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [threshold]);
return direction;
}
module.exports = { useScrollDirection };
Three shifts from the naive version. The previous position moves from useState to a useRef, so the handler reads lastY.current live — the closure is never stale — and mutating it does not trigger a render. A threshold guard drops sub-threshold moves before they can flip anything. And setDirection gets a functional update that returns the previous value unchanged when the direction is the same, so React bails out and same-direction scrolls cause no re-render. The listener is { passive: true } and is removed on unmount.
Start at the top of the page with the default threshold of 6. On mount, lastY.current is 0 and direction is null.
+40, past the threshold, so next is 'down'. Set lastY.current = 40 and flip direction from null to 'down'. One re-render; the header hides.+50, still 'down'. Advance lastY.current = 90, but setDirection returns the same 'down', so React bails — no re-render.-2, under the threshold, so the handler returns early. The baseline stays 90; direction stays 'down'.-40 against the fresh baseline of 90, past the threshold, so next is 'up'. Set lastY.current = 50 and flip direction to 'up'. The header slides back in.Four scroll events, two state changes — only the genuine flips. Position updates on every frame; direction is a stable enum that changes rarely, which is exactly what downstream UI wants to gate on.
useState and reading it from a handler you subscribed once ([] deps) closes over the first value, so every scroll is compared against 0. Hold it in a useRef and mutate lastY.current each event.threshold, and leave the baseline unmoved so the band stays put.setState on each event (or storing the position in state) re-renders dozens of times a second even when nothing changed. Keep the position in a ref and only setDirection on a flip.window.scrollY inside the handler when it fires, not when the effect first runs.scroll listener makes the browser wait on your handler before scrolling. Pass { passive: true } since you never call preventDefault.el.scrollTop to track direction inside any scroll container, not just the window.window.scrollX yields 'left' and 'right'; some libraries report all four.useScrollLock) or react-use (which has useScroll and useWindowScroll, position only). Mantine's use-scroll-direction keeps the previous position in a ref and returns 'up' or 'down', but applies no threshold. reactuse's useScroll exposes direction as four booleans with throttle and idle timing, and ahooks' useScroll gives you the position to derive direction from.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.