Skip to main content

Overview

useMove enables pointer/touch scrubbing on an element and reports normalized coordinates. Values passed to onChange are clamped to 0..1; horizontal values are mirrored in rtl mode.

Installation

npm i @kuzenbo/hooks

Import

import { useMove } from "@kuzenbo/hooks";

Usage

Basic Slider

import { useMove } from "@kuzenbo/hooks";
import { useState } from "react";

export function ColorSlider() {
  const [value, setValue] = useState({ x: 0, y: 0 });
  const { ref, active } = useMove(setValue);

  return (
    <div>
      <div
        ref={ref}
        className="relative h-32 w-full bg-gradient-to-r from-blue-500 to-red-500 rounded-lg cursor-pointer"
      >
        <div
          className="absolute h-4 w-4 bg-white rounded-full border-2 border-black transform -translate-x-1/2 -translate-y-1/2"
          style={{
            left: `${value.x * 100}%`,
            top: `${value.y * 100}%`,
          }}
        />
      </div>
      <p className="mt-2 text-sm">
        Position: x={value.x.toFixed(2)}, y={value.y.toFixed(2)}
        {active && " (scrubbing)"}
      </p>
    </div>
  );
}

With Callbacks

import { useMove } from "@kuzenbo/hooks";
import { useState } from "react";

export function SliderWithCallbacks() {
  const [value, setValue] = useState({ x: 0.5, y: 0.5 });
  const [scrubbing, setScrubbing] = useState(false);

  const { ref, active } = useMove(
    setValue,
    {
      onScrubStart: () => setScrubbing(true),
      onScrubEnd: () => setScrubbing(false),
    }
  );

  return (
    <div>
      <div
        ref={ref}
        className="relative h-32 w-full bg-muted rounded-lg cursor-pointer"
      >
        <div
          className="absolute h-3 w-3 bg-primary rounded-full transform -translate-x-1/2 -translate-y-1/2"
          style={{
            left: `${value.x * 100}%`,
            top: `${value.y * 100}%`,
          }}
        />
      </div>
      <p className="mt-2 text-sm">
        {scrubbing ? "Scrubbing..." : "Ready"}
      </p>
    </div>
  );
}

RTL Support

import { useMove } from "@kuzenbo/hooks";
import { useState } from "react";

export function RTLSlider() {
  const [value, setValue] = useState({ x: 0, y: 0 });
  const { ref } = useMove(setValue, undefined, "rtl");

  return (
    <div
      ref={ref}
      className="relative h-20 w-full bg-muted rounded-lg cursor-pointer"
      dir="rtl"
    >
      <div
        className="absolute h-3 w-3 bg-primary rounded-full transform -translate-x-1/2 -translate-y-1/2"
        style={{
          left: `${value.x * 100}%`,
          top: `${value.y * 100}%`,
        }}
      />
    </div>
  );
}

API Reference

function useMove<T extends HTMLElement = HTMLElement>(
  onChange: (value: UseMovePosition) => void,
  handlers?: UseMoveHandlers,
  dir?: "ltr" | "rtl"
): UseMoveReturnValue<T>
onChange
(value: UseMovePosition) => void
required
Called on pointer movement with normalized { x, y } values (0..1)
handlers
UseMoveHandlers
Optional callbacks fired when scrubbing starts or ends
handlers.onScrubStart
() => void
Called when scrubbing starts
handlers.onScrubEnd
() => void
Called when scrubbing ends
dir
'ltr' | 'rtl'
default:"ltr"
Horizontal direction used to interpret x values
ref
RefCallback<T | null>
Ref callback to attach to the scrubbing element
active
boolean
true when the user is currently scrubbing

Type Definitions

interface UseMovePosition {
  x: number;
  y: number;
}

interface UseMoveHandlers {
  onScrubStart?: () => void;
  onScrubEnd?: () => void;
}

interface UseMoveReturnValue<T extends HTMLElement = HTMLElement> {
  ref: RefCallback<T | null>;
  active: boolean;
}

Caveats

  • Values are automatically clamped to 0..1 range
  • In RTL mode, horizontal values are mirrored (1 becomes 0, 0 becomes 1)
  • Uses requestAnimationFrame for smooth updates
  • Sets user-select: none on the element during scrubbing

SSR and RSC Notes

  • Use this hook in Client Components only
  • Do not call it from React Server Components

Build docs developers (and LLMs) love