This component is used in the Number Input component to implement the drag behavior.
px
Installation
Copy and paste the following code into your project.
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
const getStepValue = (
e: MouseEvent | React.WheelEvent | React.KeyboardEvent,
lockToStep: boolean
): number => {
let step = 1;
if (e.ctrlKey) {
step = 100;
} else if (e.shiftKey) {
step = 10;
} else if (e.altKey && !lockToStep) {
step = 0.1;
}
return step;
};
/**
* Get the inner y position of the window.
* @param y
* @returns The inner y position of the window.
*/
const getWindowInnerYPosition = (y: number): number => {
let finalY = y % window.innerHeight;
if (finalY < 0) {
finalY += window.innerHeight;
}
return finalY;
};
/**
* Get the inner x position of the window.
* @param x
* @returns The inner x position of the window.
*/
const getWindowInnerXPosition = (x: number): number => {
let finalX = x % window.innerWidth;
if (finalX < 0) {
finalX += window.innerWidth;
}
return finalX;
};
export interface UseInputDragControlParams {
/**
* The container able to trigger the drag control.
*/
containerRef: React.RefObject<HTMLElement | null>;
/**
* The value of the number input.
*/
value: number;
/**
* The step of the number input.
* @default 1
*/
step?: number;
/**
* The minimum value of the number input.
*/
min?: number;
/**
* The maximum value of the number input.
*/
max?: number;
/**
* The function to call when the value changes.
*/
onDrag?: (newVal: number) => void;
/**
* Scale factor for drag sensitivity. Higher values make dragging more sensitive.
* @default 1
*/
dragScale?: number;
/**
* When true, disables ALT key 0.1 multiplier and snaps value to multiples of step.
* @default false
*/
lockToStep?: boolean;
/**
* Drag direction. Vertical: top is positive. Horizontal: right is positive.
* @default 'vertical'
*/
dragDirection?: 'vertical' | 'horizontal';
}
export const useInputDragControl = ({
containerRef,
value,
step = 1,
min,
max,
onDrag,
dragScale = 1,
lockToStep = false,
dragDirection = 'vertical',
}: UseInputDragControlParams) => {
const [isDragging, setIsDragging] = useState(false);
const [xLocation, setXLocation] = useState(0);
const [yLocation, setYLocation] = useState(0);
const valueRef = useRef(value);
const accumRef = useRef(0); // accumulate raw pointer movement along chosen axis
// Keep an up-to-date reference to the external value so new drags
// always start from the latest value
useEffect(() => {
valueRef.current = value;
}, [value]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Don't prevent default - let the input focus normally
const currentValue = valueRef.current;
const startX = e.clientX;
const startY = e.clientY;
setXLocation(startX);
setYLocation(startY);
let initialValue = currentValue;
let delta = 0;
let hasMoved = false;
const moveThreshold = 3; // pixels threshold to start dragging
const handleMouseMove = (e: MouseEvent) => {
const deltaX = Math.abs(e.clientX - startX);
const deltaY = Math.abs(e.clientY - startY);
// Only start dragging if we've moved beyond the threshold
if (!hasMoved && (deltaX > moveThreshold || deltaY > moveThreshold)) {
hasMoved = true;
setIsDragging(true);
// Request pointer lock for smooth dragging
if (containerRef.current) {
containerRef.current.requestPointerLock();
}
accumRef.current = 0;
}
if (hasMoved) {
const pxPerStep = Math.max(1, dragScale);
const finalStepUnit = step * getStepValue(e, lockToStep);
// Compute delta based on axis: vertical (top is positive), horizontal (right is positive)
const movementY = e.movementY;
const movementX = e.movementX;
const axisMovement = dragDirection === 'vertical' ? -movementY : movementX;
// accumulate axis movement and only apply value change when we reached a multiple of pxPerStep
accumRef.current += axisMovement;
const stepsCount = (accumRef.current / pxPerStep) | 0; // truncate toward zero
if (stepsCount !== 0) {
accumRef.current -= stepsCount * pxPerStep;
delta += stepsCount * finalStepUnit;
}
const newVal = initialValue + delta;
// Handle min/max clamping
let finalValue = newVal;
if (max !== undefined && newVal > max) {
finalValue = max;
delta = max - initialValue;
} else if (min !== undefined && newVal < min) {
finalValue = min;
delta = min - initialValue;
}
// Update cursor position using movement deltas
setXLocation((prevX) => {
const newX = prevX + e.movementX;
return getWindowInnerXPosition(newX);
});
setYLocation((prevY) => {
const newY = prevY + e.movementY;
return getWindowInnerYPosition(newY);
});
onDrag?.(finalValue);
}
};
const handleMouseUp = () => {
if (hasMoved) {
document.exitPointerLock();
setIsDragging(false);
}
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[step, min, max, onDrag, containerRef, dragScale, lockToStep]
);
// Handle custom cursor
useEffect(() => {
const cursor = document.getElementById('locked-cursor');
if (isDragging) {
if (cursor) {
if (document.pointerLockElement) {
cursor.style.top = yLocation + 'px';
cursor.style.left = xLocation + 'px';
cursor.style.transform = `${dragDirection === 'horizontal' ? 'rotate(90deg)' : 'rotate(0deg)'}`;
}
} else {
// Create custom drag cursor
const createdCursor = document.createElement('div');
createdCursor.innerHTML = `
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="24" width="24" xmlns="http://www.w3.org/2000/svg">
<path stroke="white" stroke-linecap="round" stroke-width="1" d="M13 6.99h3L12 3 8 6.99h3v10.02H8L12 21l4-3.99h-3z"></path>
</svg>
`;
createdCursor.classList.add('drag-icon');
createdCursor.id = 'locked-cursor';
createdCursor.style.cssText = `
position: fixed;
pointer-events: none;
z-index: 9999;
color: #6b7280;
transform: ${dragDirection === 'horizontal' ? 'rotate(90deg)' : 'rotate(0deg)'};
`;
document.body.append(createdCursor);
createdCursor.style.top = yLocation + 'px';
createdCursor.style.left = xLocation + 'px';
}
} else if (cursor) {
cursor.remove();
}
}, [xLocation, yLocation, isDragging, dragDirection]);
return { handleMouseDown, isDragging };
};
Nothing else, no dependencies.
Usage
import { useInputDragControl } from '@kit/ui/hooks/use-input-drag-control';
const { handleMouseDown, isDragging } = useInputDragControl({
containerRef: localRef,
value: currentValue,
step,
min,
max,
onDrag: onValueChange,
dragScale,
lockToStep,
dragDirection,
});
Look at the API Reference for more details on the parameters.
Features
- Drag controls
- Inifnite drag
- Keyboard support
- Scale, direction, step customization
Control step factor with the keyboard
You can control the step factor with the keyboard by holding the ALT
, SHIFT
, or CTRL
key.

Number Input component anatomy
API Reference
UseInputDragControlParams
Prop | Type | Default |
---|---|---|
containerRef* | React.RefObject<HTMLElement | null> | |
value* | number | |
step | number | 1 |
min | number | |
max | number | |
onDrag | function | |
dragScale | number | 1 |
lockToStep | boolean | false |
dragDirection | "vertical" | "horizontal" | 'vertical' |
How is this guide?
Last updated on 10/17/2025