Number Input
An enhanced number input with keyboard, mouse wheel, and drag controls.
Installation
Make sure that the following shadcn/ui components are present in your project:
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 };
};
'use client';
import { cn } from '@kit/shared';
import { Input } from '@kit/ui/input';
import { Slot } from '@radix-ui/react-slot';
import React, {
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { useInputDragControl } from '../hooks/use-input-drag-control';
interface NumberInputContextValue {
value: number | '';
min?: number;
max?: number;
step: number;
unit?: string;
onValueChange: (value: number | '') => void;
}
const NumberInputContext = createContext<NumberInputContextValue | null>(null);
const useNumberInputContext = () => {
const context = useContext(NumberInputContext);
if (!context) {
throw new Error('NumberInput components must be used within NumberInputRoot');
}
return context;
};
export interface NumberInputRootProps {
/**
* Whether to render the number input as a child component.
* @default false
*/
asChild?: boolean;
value?: number | '';
defaultValue?: number;
/**
* The unit of the number input.
*/
unit?: string;
/**
* The minimum value of the number input.
*/
min?: number;
/**
* The maximum value of the number input.
*/
max?: number;
/**
* The step of the number input.
* @default 1
*/
step?: number;
onValueChange?: (value: number | '') => void;
className?: string;
}
const NumberInputRoot = forwardRef<HTMLDivElement, NumberInputRootProps & React.PropsWithChildren>(
(
{
asChild = false,
children,
value: propValue,
defaultValue = 0,
min,
max,
step = 1,
unit,
onValueChange,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : 'div';
const prevPropValueRef = useRef(propValue);
const [internalValue, setInternalValue] = useState<number | ''>(propValue ?? defaultValue);
// Use controlled state if open prop is provided, otherwise use internal state
const finalValue = propValue !== undefined ? propValue : internalValue;
const handleValueChange = useCallback(
(value: number | '' | ((prev: number | '') => number | '')) => {
const newValue = typeof value === 'function' ? value(finalValue) : value;
if (propValue === undefined) {
// Only update internal state if not controlled
setInternalValue(newValue);
}
// Always call onOpenChange if provided
onValueChange?.(newValue);
},
[finalValue, propValue, onValueChange]
);
// Sync with controlled prop value
useEffect(() => {
if (typeof propValue === 'number' && propValue !== prevPropValueRef.current) {
setInternalValue(propValue);
prevPropValueRef.current = propValue;
}
}, [propValue]);
const contextValue: NumberInputContextValue = {
value: internalValue,
min,
max,
step,
unit,
onValueChange: handleValueChange,
};
return (
<NumberInputContext.Provider value={contextValue}>
<Comp ref={ref} className={cn('flex items-center gap-2', className)} {...props}>
{children}
</Comp>
</NumberInputContext.Provider>
);
}
);
NumberInputRoot.displayName = 'NumberInputRoot';
export interface DragWheelControlsProps {
/**
* Whether to render the drag and wheel controls as a child component.
* @default false
*/
asChild?: boolean;
/**
* Whether to enable drag functionality.
* @default false
*/
disableDrag?: boolean;
/**
* Whether to enable wheel functionality.
* @default false
*/
disableWheel?: boolean;
/**
* Scale factor for drag sensitivity. Higher values make dragging more sensitive.
* @default 1
*/
dragScale?: number;
/**
* Scale factor for wheel sensitivity. Higher values make wheel scrolling more sensitive.
* @default 1
*/
wheelScale?: number;
/**
* When true, disables ALT key 0.1 multiplier and snaps value to multiples of step.
* @default false
*/
lockToStep?: boolean;
/**
* Drag axis. Vertical: top is positive. Horizontal: right is positive.
* @default 'vertical'
*/
dragDirection?: 'vertical' | 'horizontal';
}
const DragWheelControls = forwardRef<
HTMLDivElement,
DragWheelControlsProps & React.HTMLAttributes<HTMLDivElement>
>(
(
{
children,
asChild = false,
disableDrag = false,
disableWheel = false,
dragScale = 1,
wheelScale = 1,
lockToStep = false,
dragDirection = 'vertical',
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : 'div';
const { value, onValueChange, min, max, step } = useNumberInputContext();
const localRef = useRef<HTMLDivElement>(null);
const currentValue = typeof value === 'number' ? value : (min ?? 0);
// Handle wheel increment
useEffect(() => {
const container = localRef.current;
if (!container || disableWheel) return;
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
event.stopPropagation();
const direction = event.deltaY < 0 ? 1 : -1;
const threshold = Math.max(1, Math.round(wheelScale));
const anyContainer = container as unknown as { __wheelAccum?: number };
anyContainer.__wheelAccum = (anyContainer.__wheelAccum ?? 0) + direction;
const stepsCount = (anyContainer.__wheelAccum / threshold) | 0;
if (stepsCount === 0) return;
anyContainer.__wheelAccum -= stepsCount * threshold;
const increment = stepsCount * step;
const newValue = Math.round((currentValue + increment) / step) * step;
const clampedValue = Math.min(max ?? Infinity, Math.max(min ?? -Infinity, newValue));
onValueChange(clampedValue);
};
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}, [disableWheel, currentValue, step, wheelScale, min, max, onValueChange]);
const { handleMouseDown, isDragging } = useInputDragControl({
containerRef: localRef,
value: currentValue,
step,
min,
max,
onDrag: onValueChange,
dragScale,
lockToStep,
dragDirection,
});
return (
<Comp
ref={(node) => {
localRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
(ref as React.RefObject<HTMLDivElement | null>).current = node;
}
}}
onMouseDown={!disableDrag ? handleMouseDown : undefined}
style={{
cursor: isDragging
? dragDirection === 'horizontal'
? 'ew-resize'
: 'ns-resize'
: 'default',
}}
className={cn('flex items-center gap-2', className)}
{...props}
>
{children}
</Comp>
);
}
);
DragWheelControls.displayName = 'DragWheelControls';
const NumberInputBase = forwardRef<
HTMLInputElement,
Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'min' | 'max' | 'step'>
>(({ className, ...props }, ref) => {
const { value, onValueChange, min, max, step, unit } = useNumberInputContext();
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value === '' ? '' : Number(event.target.value);
onValueChange(newValue);
},
[onValueChange]
);
const handleBlur = useCallback(() => {
if (typeof value === 'number') {
const clampedValue = Math.min(max ?? Infinity, Math.max(min ?? -Infinity, value));
onValueChange(clampedValue);
} else {
onValueChange(min ?? 0);
}
}, [value, min, max, onValueChange]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
const direction = event.key === 'ArrowUp' ? 'up' : 'down';
const increment = direction === 'up' ? step : -step;
const currentValue = typeof value === 'number' ? value : (min ?? 0);
const newValue = Math.round((currentValue + increment) / step) * step;
const clampedValue = Math.min(max ?? Infinity, Math.max(min ?? -Infinity, newValue));
onValueChange(clampedValue);
}
},
[value, step, min, max, onValueChange]
);
return (
<Input
ref={ref || inputRef}
type="number"
value={value}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
min={min}
max={max}
step={step}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={typeof value === 'number' ? value : undefined}
aria-valuetext={typeof value === 'number' ? `${value}${unit ? ` ${unit}` : ''}` : undefined}
className={cn('flex-1', className)}
{...props}
/>
);
});
NumberInputBase.displayName = 'NumberInputBase';
export interface NumberInputUnitProps {
/**
* Whether to render the number input unit as a child component.
* @default false
*/
asChild?: boolean;
}
const NumberInputUnit = forwardRef<
HTMLDivElement,
NumberInputUnitProps & React.HTMLAttributes<HTMLDivElement>
>(({ className, children, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'div';
const { unit } = useNumberInputContext();
const displayUnit = children || unit;
if (!displayUnit) return null;
return (
<Comp
ref={ref}
className={cn('text-muted-foreground text-sm whitespace-nowrap', className)}
{...props}
>
{displayUnit}
</Comp>
);
});
NumberInputUnit.displayName = 'NumberInputUnit';
/* _______Higher Level Component________ */
export interface NumberInputProps extends Omit<NumberInputRootProps, 'children' | 'asChild'> {
controls: DragWheelControlsProps;
base: React.ComponentProps<typeof NumberInputBase>;
}
const NumberInput: React.FC<NumberInputProps> = ({ controls, base, ...props }) => {
return (
<NumberInputRoot {...props} asChild>
<DragWheelControls {...controls}>
<NumberInputBase {...base} />
<NumberInputUnit />
</DragWheelControls>
</NumberInputRoot>
);
};
export { DragWheelControls, NumberInput, NumberInputBase, NumberInputRoot, NumberInputUnit };
Update the import paths to match your project setup.
Usage
import { DragWheelControls, NumberInputBase, NumberInputRoot, NumberInputUnit } from '@kit/ui/number-input';
<NumberInputRoot unit="px">
<DragWheelControls>
<NumberInputBase placeholder="Your placeholder here" className="w-24 text-right" />
<NumberInputUnit />
</DragWheelControls>
</NumberInputRoot>
Or you can use the NumberInput
if you don't need that much customization.
import { NumberInput } from '@kit/ui/number-input';
<NumberInput unit="px" />
Features
- Keyboard arrow key controls (↑↓)
- Mouse wheel scrolling support
- Click and drag vertical adjustment
- Unit display support
- Min/max value constraints
- Decimal step increments
- Accessible ARIA attributes
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
Examples
Horizontal Drag
Switch the drag direction to horizontal.
Custom Scale
Control the sensitivity of the drag and wheel controls.
Min/Max
Base
The NumberInputBase
component is a simpler version of the NumberInput
component without drag and wheel controls.
API Reference
We are exposing 2 components
Root
NumberInputRootProps
Prop | Type | Default |
---|---|---|
asChild | boolean | false |
value | number | "" | |
defaultValue | number | |
unit | string | |
min | number | |
max | number | |
step | number | 1 |
onValueChange | function | |
className | string |
Drag & Wheel Controls
DragWheelControlsProps
Prop | Type | Default |
---|---|---|
asChild | boolean | false |
disableDrag | boolean | false |
disableWheel | boolean | false |
dragScale | number | 1 |
wheelScale | number | 1 |
lockToStep | boolean | false |
dragDirection | "vertical" | "horizontal" | 'vertical' |
Base
The input type="number"
element.
Unit
NumberInputUnitProps
Prop | Type | Default |
---|---|---|
asChild | boolean | false |
All in one
This component is a shortcut if you don't need heavy customization.
NumberInputProps
Prop | Type | Default |
---|---|---|
controls* | DragWheelControlsProps | |
base* | Omit<React.InputHTMLAttributes<HTMLInputElement>... | |
value | number | "" | |
defaultValue | number | |
unit | string | |
min | number | |
max | number | |
step | number | 1 |
onValueChange | function | |
className | string |
A full-featured media picker and manager with selection, edit, and upload.
A phone input component that simplifies the phone input.
How is this guide?
Last updated on 10/17/2025