Octocat

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 element
  • cancelCurrent: 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 calling scrollTo).

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;
  • Ocean Waves
    Powerful waves crash against a rocky coastline at sunset
  • Forest Trail
    A winding path through an ancient forest filled with towering trees
  • Desert Dunes
    Rolling sand dunes stretching endlessly toward the horizon
  • Mountain Sunrise
    A 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