A drop-in extension of Motion's useScroll hook for React that adds scroll direction tracking.
That's a wrapper around the useScroll hook from Motion. It accepts the same options as useScroll,
and returns the same values, but also it tracks the direction of the scroll.
It's useful to animate components based on the scroll direction.
npm install motionbun add motiondeno add motionpnpm add motionyarn add motion"use client";
import {
type UseScrollOptions,
useMotionValueEvent,
useScroll,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
type MotionDirX = "left" | "right" | null;
type MotionDirY = "up" | "down" | null;
type UseScrollV2Options = UseScrollOptions & {
/** When this value changes, `dirX` / `dirY` reset to `null`. */
resetKey?: string | number | null;
};
/**
* Tracks scroll position and direction on both axes via Motion `useScroll`.
*
* - `x` / `y` — `scrollX` / `scrollY` motion values
* - `scrollXProgress` / `scrollYProgress` — `scrollX` / `scrollY` progress motion values
* - `directionX` — `"left"` | `"right"` | `null` (null until the first horizontal change)
* - `directionY` — `"up"` | `"down"` | `null` (null until the first vertical change)
*
* For nested overflow scrollers (e.g. docs `main`), pass `{ container: ref }`.
* Window scroll is used when `container` is omitted.
* Pass `resetKey` (e.g. pathname) to clear direction on navigation.
*/
function useScrollV2(options?: UseScrollV2Options) {
const { resetKey, ...scrollOptions } = options ?? {};
const {
scrollX: x,
scrollY: y,
scrollXProgress,
scrollYProgress,
} = useScroll(scrollOptions);
const [directionX, setDirectionX] = useState<MotionDirX>(null);
const [directionY, setDirectionY] = useState<MotionDirY>(null);
const prevXRef = useRef<number | null>(null);
const prevYRef = useRef<number | null>(null);
// Reset direction whenever navigation key changes (e.g. docs pathname).
// biome-ignore lint/correctness/useExhaustiveDependencies: resetKey is an intentional trigger
useEffect(() => {
prevXRef.current = null;
prevYRef.current = null;
setDirectionX(null);
setDirectionY(null);
}, [resetKey]);
useMotionValueEvent(x, "change", (current) => {
const previous = prevXRef.current;
prevXRef.current = current;
if (previous === null) {
return;
}
const diff = current - previous;
if (diff === 0) {
return;
}
const next: MotionDirX = diff > 0 ? "right" : "left";
setDirectionX((prev) => (prev === next ? prev : next));
});
useMotionValueEvent(y, "change", (current) => {
const previous = prevYRef.current;
prevYRef.current = current;
if (previous === null) {
return;
}
const diff = current - previous;
if (diff === 0) {
return;
}
const next: MotionDirY = diff > 0 ? "down" : "up";
setDirectionY((prev) => (prev === next ? prev : next));
});
return { directionX, directionY, scrollXProgress, scrollYProgress, x, y };
}
export type { MotionDirX, MotionDirY, UseScrollV2Options };
export { useScrollV2 };const {
/* Original properties from `useScroll` */
scrollXProgress,
scrollYProgress,
x,
y,
/* New properties from `useScrollV2` */
directionX,
directionY,
} = useScrollV2(options);