useMousePan
A hook for scrolling elements with a mouse pan gesture
Dependencies
Source Code
import { useEffect, useRef } from 'react';
import { lerp } from '@/lib/math/lerp';
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 = `${element.style.cssText.replace(
/scroll-snap-type:.+?;/g,
'scroll-snap-type: auto;'
)}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
'use client';
import { useMousePan } from '@/foundations/hooks/use-mouse-pan';
import { cn } from '@/lib/utils/classnames';
const UseMousePanPreview = () => {
const { ref } = useMousePan<HTMLDivElement>();
return (
<div
ref={ref}
className="w-full max-w-lg cursor-grab overflow-x-auto active:cursor-grabbing"
>
<ul className="flex size-max gap-2">
{new Array(12).fill(0).map((_, index) => (
<li
key={index}
className={cn(
'no-select size-32 rounded-sm bg-foreground-secondary/15',
index % 2 === 0 && 'bg-foreground-secondary/30'
)}
/>
))}
</ul>
</div>
);
};
export default UseMousePanPreview; With Snap
'use client';
import { useMousePan } from '@/foundations/hooks/use-mouse-pan';
import { cn } from '@/lib/utils/classnames';
const UseMousePanPreview = () => {
const { ref } = useMousePan<HTMLDivElement>();
return (
<div
ref={ref}
className="w-full max-w-lg cursor-grab snap-x snap-mandatory overflow-x-auto active:cursor-grabbing"
>
<ul className="flex size-max gap-2">
{new Array(12).fill(0).map((_, index) => (
<li
key={index}
className={cn(
'no-select h-32 w-64 snap-center rounded-sm bg-foreground-secondary/15',
index % 2 === 0 && 'bg-foreground-secondary/30'
)}
/>
))}
</ul>
</div>
);
};
export default UseMousePanPreview; Both Axis
'use client';
import { useMousePan } from '@/foundations/hooks/use-mouse-pan';
import { cn } from '@/lib/utils/classnames';
const UseMousePanBothPreview = () => {
const { ref } = useMousePan<HTMLDivElement>();
return (
<div
ref={ref}
className="aspect-square h-full max-h-128 w-full max-w-lg cursor-grab snap-both snap-mandatory overflow-auto active:cursor-grabbing"
>
<ul className="grid size-max grid-cols-13 grid-rows-13">
{new Array(169).fill(0).map((_, index) => (
<li
key={index}
className={cn(
'no-select size-24 snap-center bg-foreground-secondary/10',
index % 2 === 0 && 'bg-foreground-secondary/30'
)}
/>
))}
</ul>
</div>
);
};
export default UseMousePanBothPreview; Simple Carousel
- Ocean WavesPowerful waves crash against a rocky coastline at sunset
- Forest TrailA winding path through an ancient forest filled with towering trees
- Desert DunesRolling sand dunes stretching endlessly toward the horizon
- Mountain SunriseA breathtaking view of the sun rising over misty peaks
1 / 4
'use client';
import {
Children,
createContext,
type ReactNode,
useContext,
useMemo,
useState,
} from 'react';
import {
InstanceCounterProvider,
useInstanceCounter,
} from '@/components/instance-counter';
import { useIntersectionObserver } from '@/foundations/hooks/use-intersection-observer';
import { useMousePan } from '@/foundations/hooks/use-mouse-pan';
import { Button } from '@/components/button';
import { clamp } from '@/lib/math/clamp';
import { cn } from '@/lib/utils/classnames';
const ITEMS = [
{
title: 'Ocean Waves',
description: 'Powerful waves crash against a rocky coastline at sunset',
image:
'https://images.unsplash.com/photo-1505142468610-359e7d316be0?q=80&w=960&auto=format&fit=crop',
},
{
title: 'Forest Trail',
description:
'A winding path through an ancient forest filled with towering trees',
image:
'https://images.unsplash.com/photo-1595514807053-2c594370091a?q=80&w=960&auto=format&fit=crop',
},
{
title: 'Desert Dunes',
description: 'Rolling sand dunes stretching endlessly toward the horizon',
image:
'https://images.unsplash.com/photo-1498144668414-48bf526766cf?q=80&w=960&auto=format&fit=crop',
},
{
title: 'Mountain Sunrise',
description: 'A breathtaking view of the sun rising over misty peaks',
image:
'https://images.unsplash.com/photo-1736525155507-2326a56f0606?q=80&w=960&auto=format&fit=crop',
},
];
const CarouselContext = createContext<{
activeIndex: number;
setActiveIndex: (index: number) => void;
}>({ activeIndex: 0, setActiveIndex: () => {} });
const Carousel = ({ children }: { children: ReactNode }) => {
const { ref, cancelCurrent } = useMousePan<HTMLDivElement>();
const [activeIndex, setActiveIndex] = useState(0);
const numItems = useMemo(() => Children.count(children), [children]);
const to = (newIndex: number) => {
if (!ref.current) return;
const next = clamp(0, newIndex, numItems - 1);
const child = ref.current.firstElementChild?.children[next];
if (!child || !(child instanceof HTMLElement)) return;
cancelCurrent();
ref.current.scrollTo({
left: child.offsetLeft - ref.current.offsetLeft,
behavior: 'smooth',
});
};
return (
<CarouselContext value={{ activeIndex, setActiveIndex }}>
<InstanceCounterProvider>
<div>
{/* Scroller */}
<div
ref={ref}
className="w-full max-w-md cursor-grab snap-x snap-mandatory overflow-x-auto overscroll-contain rounded-sm active:cursor-grabbing"
>
<ul
className="grid size-max w-full grid-cols-[repeat(var(--num-items),100%)] gap-2"
style={{ '--num-items': numItems }}
>
{Children.map(children, (child) => (
<li className="aspect-3/2 w-full select-none snap-center overflow-hidden rounded-sm">
{child}
</li>
))}
</ul>
</div>
{/* Controls */}
<div className="mt-3 flex w-full items-center gap-2">
<Button
disabled={activeIndex === 0}
variant="ghost"
size="sm"
onClick={() => to(activeIndex - 1)}
>
←
</Button>
<Button
disabled={activeIndex === numItems - 1}
variant="ghost"
size="sm"
onClick={() => to(activeIndex + 1)}
>
→
</Button>
<div className="ml-auto font-medium text-sm">
{activeIndex + 1} / {numItems}
</div>
</div>
</div>
</InstanceCounterProvider>
</CarouselContext>
);
};
const CarouselItem = ({
title,
description,
image,
}: {
title: string;
description: string;
image: string;
}) => {
const index = useInstanceCounter();
const { activeIndex, setActiveIndex } = useContext(CarouselContext);
const isActive = activeIndex === index;
const [ref] = useIntersectionObserver<HTMLDivElement>(
{ threshold: 0.9 },
(isIntersecting) => {
if (isIntersecting) setActiveIndex(index);
}
);
return (
<div
ref={ref}
className="flex size-full items-end bg-center bg-cover"
style={{ backgroundImage: `url(${image})` }}
>
<div className="w-full bg-linear-to-t from-foreground/85 to-transparent p-6 pt-32 pr-12 font-medium text-md">
<div
className={cn(
'text-background',
isActive && 'transition-all duration-500 ease-out',
!isActive && 'translate-y-6 opacity-0'
)}
>
{title}
</div>
<div
className={cn(
'text-background/75',
isActive && 'transition-all delay-50 duration-500 ease-out',
!isActive && 'translate-y-6 opacity-0'
)}
>
{description}
</div>
</div>
</div>
);
};
const UseMousePanCarouselPreview = () => {
return (
<Carousel>
{ITEMS.map((item, index) => (
<CarouselItem
key={index}
title={item.title}
description={item.description}
image={item.image}
/>
))}
</Carousel>
);
};
export default UseMousePanCarouselPreview; With Clickable Elements
'use client';
import { useMousePan } from '@/foundations/hooks/use-mouse-pan';
import { cn } from '@/lib/utils/classnames';
const UseMousePanClickables = () => {
const { ref } = useMousePan<HTMLDivElement>();
return (
<div
ref={ref}
className="w-full max-w-lg cursor-grab overflow-x-auto **:cursor-grab active:cursor-grabbing active:**:cursor-grabbing"
>
<ul className="flex size-max gap-2">
{new Array(12).fill(0).map((_, index) => (
<li
key={index}
className={cn(
'no-select size-32 rounded-sm bg-foreground-secondary/15',
index % 2 === 0 && 'bg-foreground-secondary/30'
)}
>
<button
type="button"
className="size-full text-sm"
onClick={() => window.alert('click')}
>
[button]
</button>
</li>
))}
</ul>
</div>
);
};
export default UseMousePanClickables; 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.
Previous
useMatchMedia
Next
usePrefersReducedMotion