useMousePan
A hook for scrolling elements with a mouse pan gesture
Dependencies
Source Code
import { lerp } from "@/lib/math/lerp";
import { useEffect, useRef } from "react";
const VELOCITY_MOMENTUM_FACTOR = 15; // multiplier for velocity added to the target scroll when pan is released
const DRAG_EASE = 1; // ease factor when holding and panning (1 = no ease)
const MOMENTUM_EASE = 0.09; // ease factor for when the pan is released
const SETTLED_THRESHOLD = 0.01; // threshold for considering the scroll position as settled
type Vector2D = { x: number; y: number };
type MouseState = {
initial: Vector2D;
};
type ScrollState = {
initial: Vector2D;
current: Vector2D;
target: Vector2D;
velocity: Vector2D;
axis: { x: boolean; y: boolean };
};
export const useMousePan = <T extends HTMLElement>() => {
const ref = useRef<T>(null);
const cancelCurrentRef = useRef<() => void>(() => {});
useEffect(() => {
const element = ref.current;
if (!element) return;
let hasSnap = false;
let isPanning = false;
let shouldPreventClick = false;
let rafId: number | null = null;
const mouse: MouseState = {
initial: { x: 0, y: 0 },
};
const scroll: ScrollState = {
initial: { x: 0, y: 0 },
current: { x: 0, y: 0 },
target: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
axis: { x: false, y: false },
};
// on pan finish (when it fully settles)
const onPanFinish = () => {
element.style.removeProperty("scroll-snap-type");
};
const cancelTick = () => {
if (!rafId) return;
window.cancelAnimationFrame(rafId);
rafId = null;
};
const requestTick = () => {
if (rafId) cancelTick();
rafId = window.requestAnimationFrame(tick);
};
const tick = () => {
rafId = null;
const previousScroll = { ...scroll.current };
const ease = isPanning ? DRAG_EASE : MOMENTUM_EASE;
// lerp to the new scroll position using the appropriate ease factor
scroll.current = {
x: lerp(scroll.current.x, scroll.target.x, ease),
y: lerp(scroll.current.y, scroll.target.y, ease),
};
// calculate the velocity of the scroll
scroll.velocity = {
x: scroll.current.x - previousScroll.x,
y: scroll.current.y - previousScroll.y,
};
const isSettled =
Math.abs(scroll.current.x - scroll.target.x) < SETTLED_THRESHOLD &&
Math.abs(scroll.current.y - scroll.target.y) < SETTLED_THRESHOLD;
// if is settled, set the current scroll to ceiled target scroll
// avoids small jitter when the target is hit and the scroll position is a decimal number
if (isSettled) {
scroll.current = {
x: Math.ceil(scroll.target.x),
y: Math.ceil(scroll.target.y),
};
// being settled and not panning means we've reached the end of this current pan animation
if (!isPanning) onPanFinish();
}
// update the scroll position if the axis is enabled
if (scroll.axis.x) element.scrollLeft = scroll.current.x;
if (scroll.axis.y) element.scrollTop = scroll.current.y;
// request another tick if the scroll is not settled
if (!isSettled) requestTick();
};
// on pan start
const onMouseDown = (event: MouseEvent) => {
isPanning = true;
shouldPreventClick = false;
// check if the element has snap
element.style.removeProperty("scroll-snap-type");
hasSnap = window.getComputedStyle(element).scrollSnapType !== "none";
// remove snap if it exists, because it prevents setting scroll positions
if (hasSnap) element.style.setProperty("scroll-snap-type", "none");
mouse.initial = {
x: event.pageX - element.offsetLeft,
y: event.pageY - element.offsetTop,
};
scroll.axis = {
x: element.scrollWidth > element.clientWidth,
y: element.scrollHeight > element.clientHeight,
};
scroll.initial = {
x: element.scrollLeft,
y: element.scrollTop,
};
// reset the state and cancel any active momentum
scroll.target = { ...scroll.initial };
scroll.current = { ...scroll.initial };
scroll.velocity = { x: 0, y: 0 };
cancelTick();
};
// on pan
const onMouseMove = (event: MouseEvent) => {
if (!isPanning) return;
const currentMouseX = event.pageX - element.offsetLeft;
const currentMouseY = event.pageY - element.offsetTop;
const walkX = currentMouseX - mouse.initial.x;
const walkY = currentMouseY - mouse.initial.y;
// prevent click if is dragging
if (Math.abs(walkX) + Math.abs(walkY) > 0) {
shouldPreventClick = true;
}
scroll.target = {
x: scroll.initial.x - walkX,
y: scroll.initial.y - walkY,
};
requestTick();
};
// on pan end
const onMouseUp = async () => {
if (!isPanning) return;
isPanning = false;
// add velocity to the target scroll to simulate momentum
const unsnappedScrollTarget = {
x: scroll.target.x + scroll.velocity.x * VELOCITY_MOMENTUM_FACTOR,
y: scroll.target.y + scroll.velocity.y * VELOCITY_MOMENTUM_FACTOR,
};
// if snap is enabled, compute the target scroll position using (a sort of) FLIP
// https://www.nan.fyi/magic-motion#introducing-flip
if (hasSnap) {
const cloneContainer = document.createElement("div");
cloneContainer.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;`;
const clone = element.cloneNode(true) as HTMLDivElement;
clone.style.cssText = `width:${element.clientWidth}px;height:${element.clientHeight}px;`;
cloneContainer.appendChild(clone);
(element.parentElement ?? element).appendChild(cloneContainer);
// we're relying on the fact that a scroll-snap element instantly snaps to the target position when its scrollLeft or scrollTop are updated
clone.scrollLeft = unsnappedScrollTarget.x;
clone.scrollTop = unsnappedScrollTarget.y;
scroll.target = { x: clone.scrollLeft, y: clone.scrollTop };
cloneContainer.remove();
// This doesn't work consistently on safari, but let's keep an eye on it because its a better and less convoluted approach
/*
const currentScroll = { x: element.scrollLeft, y: element.scrollTop };
element.style.removeProperty("scroll-snap-type");
element.scrollLeft = unsnappedScrollTarget.x;
element.scrollTop = unsnappedScrollTarget.y;
scroll.target = { x: element.scrollLeft, y: element.scrollTop };
element.style.setProperty("scroll-snap-type", "none");
element.scrollLeft = currentScroll.x;
element.scrollTop = currentScroll.y;
*/
} else {
scroll.target = { ...unsnappedScrollTarget };
}
// if the target is already hit, settle the scroll position, otherwise request another tick
if (
scroll.current.x === scroll.target.x &&
scroll.current.y === scroll.target.y
) {
onPanFinish();
} else {
requestTick();
}
};
const onClick = (event: MouseEvent) => {
if (shouldPreventClick) {
event.preventDefault();
event.stopPropagation();
}
};
// cancel all pan behavior and animation
const cancelCurrent = () => {
cancelTick();
onPanFinish();
scroll.velocity = { x: 0, y: 0 };
scroll.current = { x: element.scrollLeft, y: element.scrollTop };
};
cancelCurrentRef.current = cancelCurrent;
const abortController = new AbortController();
const signal = abortController.signal;
element.addEventListener("mousedown", onMouseDown, { signal });
element.addEventListener("mousemove", onMouseMove, { signal });
element.addEventListener("mouseup", onMouseUp, { signal });
element.addEventListener("mouseleave", onMouseUp, { signal });
element.addEventListener("wheel", cancelCurrent, { signal });
element.addEventListener("click", onClick, { signal });
return () => {
cancelCurrentRef.current = () => {};
abortController.abort();
cancelTick();
onPanFinish();
};
}, []);
return {
ref,
cancelCurrent: () => cancelCurrentRef.current(),
};
};
Features
- Scroll Snap Support - Seamlessly works with CSS scroll-snap properties for precise control
- Multi-Axis Support - Handles both horizontal and vertical scrolling directions
- Native Scroll Integration - Preserves native scrolling behavior for wheel and touch events, allowing for a seamless cross-device experience
API Reference
The hooks returns an object with:
ref
: A ref to attach to a scrollable elementcancelCurrent
: A function that stops the currently active pan interaction and/or animation. Useful when you need to manually control the element's scroll position (e.g. when callingscrollTo
).
Examples
Basic
With Snap
Both Axis
Simple Carousel
With Clickable Elements
Best Practices
For better usability, set the cursor to grab
by default and grabbing
when actively panning ("cursor-grab active:cursor-grabbing"
, if using Tailwind). This provides a clear visual indication that the element is draggable.