Numeric Input
import {
InputMultiRoot,
NumericInput,
NumericInputPrimitive,
} from '@/components/ui/input';
export default function NumericInputDemo() {
return (
<div className='flex flex-col gap-2'>
<NumericInput className='w-32' defaultValue={12} min={0} max={100} />
<NumericInput
className='w-32'
defaultValue={12}
min={0}
max={100}
iconLead={'X'}
/>
<InputMultiRoot>
<NumericInputPrimitive
value={12}
iconLead={
<CornerIcon className='text-black-500 dark:text-white-500 size-6' />
}
className={'w-6'}
/>
<NumericInputPrimitive value={12} className={'w-6'} />
<NumericInputPrimitive value={12} className={'w-6'} />
<NumericInputPrimitive value={12} className={'w-6'} />
</InputMultiRoot>
</div>
);
}
function CornerIcon({ className }: { className: string }) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='currentColor'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M12.4781 8L12.5 8H15.5C15.7761 8 16 8.22386 16 8.5C16 8.77614 15.7761 9 15.5 9H12.5C11.7917 9 11.2905 9.00039 10.8987 9.0324C10.5128 9.06393 10.2772 9.12365 10.092 9.21799C9.7157 9.40973 9.40973 9.7157 9.21799 10.092C9.12365 10.2772 9.06393 10.5128 9.0324 10.8987C9.00039 11.2905 9 11.7917 9 12.5V15.5C9 15.7761 8.77614 16 8.5 16C8.22386 16 8 15.7761 8 15.5V12.5L8 12.4781C8 11.7966 7.99999 11.2546 8.03572 10.8173C8.07231 10.3695 8.14884 9.98765 8.32698 9.63803C8.6146 9.07354 9.07354 8.6146 9.63803 8.32698C9.98765 8.14884 10.3695 8.07231 10.8173 8.03572C11.2546 7.99999 11.7966 8 12.4781 8Z'
fill='currentColor'
/>
</svg>
);
}
Manual Installation
Make sure to add input-utils.tsx and text-input.tsx to your project.
'use client';
import {
InputRoot,
type BaseInputProps,
useInputRootContext,
} from './input-utils';
import { TextInputPrimitive } from './text-input';
import { Input as BaseInput } from '@base-ui-components/react';
import React from 'react';
const NUMBER_REGEX = /^-?\d+(\.\d+)?$/;
const OPERATORS_REGEX = /[+\-*/()]/;
const PREC: Record<string, number> = {
'u-': 3,
'*': 2,
'/': 2,
'+': 1,
'-': 1,
};
const RIGHT_ASSOC = new Set(['u-']);
const toStringValue = (v: unknown): string => {
if (typeof v === 'number') return String(v);
if (typeof v === 'string') return v;
return '';
};
const isValidNumber = (v: string): boolean => NUMBER_REGEX.test(v);
const getDecimalPlaces = (n: number): number => {
const s = String(n);
const i = s.indexOf('.');
return i >= 0 ? s.length - i - 1 : 0;
};
const trimTrailingZeros = (s: string): string => {
if (!s.includes('.')) return s;
return s.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
};
const evaluateExpression = (expr: string): number | null => {
const tokens: (number | string)[] = [];
const src = expr.replace(/\s+/g, '');
let i = 0;
const isDigit = (c: string) => /[0-9]/.test(c);
while (i < src.length) {
const c = src[i];
if (isDigit(c) || c === '.') {
let j = i + 1;
while (j < src.length && (isDigit(src[j]) || src[j] === '.')) j++;
const n = Number(src.slice(i, j));
if (!Number.isFinite(n)) return null;
tokens.push(n);
i = j;
} else if (
c === '+' ||
c === '-' ||
c === '*' ||
c === '/' ||
c === '(' ||
c === ')'
) {
const prev = tokens[tokens.length - 1];
if (
c === '-' &&
(prev === undefined ||
(typeof prev === 'string' &&
(prev === '+' ||
prev === '-' ||
prev === '*' ||
prev === '/' ||
prev === '(')))
) {
tokens.push('u-');
i++;
} else {
tokens.push(c);
i++;
}
} else {
return null;
}
}
const output: (number | string)[] = [];
const ops: string[] = [];
for (const t of tokens) {
if (typeof t === 'number') {
output.push(t);
} else if (t === '(') {
ops.push(t);
} else if (t === ')') {
while (ops.length && ops[ops.length - 1] !== '(')
output.push(ops.pop() as string);
if (ops.pop() !== '(') return null;
} else {
while (
ops.length &&
ops[ops.length - 1] !== '(' &&
((RIGHT_ASSOC.has(t) && PREC[t] < PREC[ops[ops.length - 1]]) ||
(!RIGHT_ASSOC.has(t) && PREC[t] <= PREC[ops[ops.length - 1]]))
)
output.push(ops.pop() as string);
ops.push(t);
}
}
while (ops.length) {
const op = ops.pop() as string;
if (op === '(') return null;
output.push(op);
}
const stack: number[] = [];
for (const t of output) {
if (typeof t === 'number') stack.push(t);
else if (t === 'u-') {
const a = stack.pop();
if (a === undefined) return null;
stack.push(-a);
} else {
const b = stack.pop();
const a = stack.pop();
if (a === undefined || b === undefined) return null;
let r: number;
if (t === '+') r = a + b;
else if (t === '-') r = a - b;
else if (t === '*') r = a * b;
else if (t === '/') r = a / b;
else return null;
if (!Number.isFinite(r)) return null;
stack.push(r);
}
}
if (stack.length !== 1) return null;
return stack[0];
};
const clampNumber = (num: number, minNum?: number, maxNum?: number): number => {
let result = num;
if (minNum !== undefined && Number.isFinite(minNum) && result < minNum)
result = minNum;
if (maxNum !== undefined && Number.isFinite(maxNum) && result > maxNum)
result = maxNum;
return result;
};
interface NumericInputProps extends BaseInputProps {
nudgeAmount?: number;
min?: number | string;
max?: number | string;
onValueChange?: (next: string) => void;
}
function NumericInputPrimitive({
onChange,
onBlur,
onKeyDown,
value,
defaultValue,
nudgeAmount = 1,
min,
max,
className,
onValueChange,
...props
}: NumericInputProps) {
type BaseInputChangeEvent = Parameters<
NonNullable<React.ComponentProps<typeof BaseInput>['onChange']>
>[0];
type BaseInputBlurEvent = Parameters<
NonNullable<React.ComponentProps<typeof BaseInput>['onBlur']>
>[0];
type BaseInputKeyDownEvent = Parameters<
NonNullable<React.ComponentProps<typeof BaseInput>['onKeyDown']>
>[0];
type BaseInputMouseDownEvent = Parameters<
NonNullable<React.ComponentProps<typeof BaseInput>['onMouseDown']>
>[0];
const initial = toStringValue(value ?? defaultValue ?? '');
const [inputValue, setInputValue] = React.useState<string>(initial);
const lastValidRef = React.useRef<string>(
isValidNumber(initial) ? initial : '',
);
const { setIsMiddleButtonDragging } = useInputRootContext();
const dragActiveRef = React.useRef<boolean>(false);
const dragStartXRef = React.useRef<number>(0);
const dragBaseRef = React.useRef<number>(0);
const dragStepRef = React.useRef<number>(Number(nudgeAmount ?? 1));
const dragDecimalsRef = React.useRef<number>(0);
const dragLastStepsRef = React.useRef<number>(0);
const minNumber = React.useMemo(
() => (typeof min === 'string' ? Number(min) : min),
[min],
);
const maxNumber = React.useMemo(
() => (typeof max === 'string' ? Number(max) : max),
[max],
);
React.useEffect(() => {
if (value !== undefined) {
const s = toStringValue(value);
setInputValue(s);
if (isValidNumber(s)) lastValidRef.current = s;
}
}, [value]);
const handleChange = React.useCallback(
(e: BaseInputChangeEvent) => {
const next = e.target.value;
setInputValue(next);
if (isValidNumber(next)) {
lastValidRef.current = next;
}
onValueChange?.(next);
onChange?.(e);
},
[onChange, onValueChange],
);
// Basic arithmetic expression evaluator: supports + - * / and parentheses, with unary minus
const numericInputCommit = React.useCallback(() => {
let next = inputValue;
const containsOperators = OPERATORS_REGEX.test(inputValue);
if (containsOperators) {
const result = evaluateExpression(inputValue);
if (result !== null) {
const clamped = clampNumber(result, minNumber, maxNumber);
next = String(clamped);
} else if (isValidNumber(lastValidRef.current)) {
const clamped = clampNumber(
Number(lastValidRef.current),
minNumber,
maxNumber,
);
next = String(clamped);
} else {
next = lastValidRef.current;
}
} else if (!isValidNumber(inputValue)) {
if (isValidNumber(lastValidRef.current)) {
const clamped = clampNumber(
Number(lastValidRef.current),
minNumber,
maxNumber,
);
next = String(clamped);
} else {
next = lastValidRef.current;
}
} else {
const clamped = clampNumber(Number(inputValue), minNumber, maxNumber);
next = String(clamped);
}
setInputValue(next);
lastValidRef.current = next;
onValueChange?.(next);
}, [inputValue, minNumber, maxNumber, onValueChange]);
const handleBlur = React.useCallback(
(e: BaseInputBlurEvent) => {
numericInputCommit();
onBlur?.(e);
},
[numericInputCommit, onBlur],
);
const handleKeyDown = React.useCallback(
(e: BaseInputKeyDownEvent) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const direction = e.key === 'ArrowUp' ? 1 : -1;
const containsOperators = OPERATORS_REGEX.test(inputValue);
let base: number | null = null;
if (containsOperators) base = evaluateExpression(inputValue);
if (base === null) {
if (isValidNumber(inputValue)) base = Number(inputValue);
else if (isValidNumber(lastValidRef.current))
base = Number(lastValidRef.current);
else base = 0;
}
const step = Number(nudgeAmount ?? 1);
const decimals = Math.max(
getDecimalPlaces(base),
getDecimalPlaces(step),
typeof minNumber === 'number' ? getDecimalPlaces(minNumber) : 0,
typeof maxNumber === 'number' ? getDecimalPlaces(maxNumber) : 0,
);
const nextNumber = clampNumber(
base + direction * step,
minNumber,
maxNumber,
);
const nextString = trimTrailingZeros(nextNumber.toFixed(decimals));
setInputValue(nextString);
lastValidRef.current = nextString;
onValueChange?.(nextString);
} else if (e.key === 'Enter') {
numericInputCommit();
e.currentTarget.blur();
}
onKeyDown?.(e);
},
[
inputValue,
maxNumber,
minNumber,
nudgeAmount,
numericInputCommit,
onKeyDown,
onValueChange,
],
);
const handleMouseDown = React.useCallback(
(e: BaseInputMouseDownEvent) => {
if (e.button !== 1) return;
e.preventDefault();
const inputEl = (e.currentTarget as unknown as HTMLElement) ?? null;
const containsOperators = OPERATORS_REGEX.test(inputValue);
let base: number | null = null;
if (containsOperators) base = evaluateExpression(inputValue);
if (base === null) {
if (isValidNumber(inputValue)) base = Number(inputValue);
else if (isValidNumber(lastValidRef.current))
base = Number(lastValidRef.current);
else base = 0;
}
dragActiveRef.current = true;
setIsMiddleButtonDragging(true);
dragStartXRef.current = (e as unknown as MouseEvent).clientX;
dragBaseRef.current = base;
dragStepRef.current = Number(nudgeAmount ?? 1);
dragDecimalsRef.current = Math.max(
getDecimalPlaces(base),
getDecimalPlaces(dragStepRef.current),
);
dragLastStepsRef.current = 0;
const pixelsPerStep = 8;
const onMove = (ev: MouseEvent) => {
if (!dragActiveRef.current) return;
const dx = ev.clientX - dragStartXRef.current;
const steps = Math.trunc(dx / pixelsPerStep);
if (steps === dragLastStepsRef.current) return;
dragLastStepsRef.current = steps;
const nextNumber = clampNumber(
dragBaseRef.current + steps * dragStepRef.current,
minNumber,
maxNumber,
);
const nextString = trimTrailingZeros(
nextNumber.toFixed(dragDecimalsRef.current),
);
setInputValue(nextString);
lastValidRef.current = nextString;
onValueChange?.(nextString);
};
const endDrag = () => {
if (!dragActiveRef.current) return;
dragActiveRef.current = false;
setIsMiddleButtonDragging(false);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
if (inputEl) inputEl.style.cursor = '';
};
const onUp = () => {
endDrag();
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp, { once: true });
document.body.style.cursor = 'ew-resize';
if (inputEl) inputEl.style.cursor = 'ew-resize';
},
[
inputValue,
maxNumber,
minNumber,
nudgeAmount,
onValueChange,
setIsMiddleButtonDragging,
],
);
return (
<TextInputPrimitive
type='text'
inputMode='decimal'
className={className}
min={min as any}
max={max as any}
value={inputValue}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onMouseDown={handleMouseDown}
{...props}
/>
);
}
function NumericInput({ className, iconLead, ...props }: NumericInputProps) {
return (
<InputRoot className={className}>
<NumericInputPrimitive iconLead={iconLead} {...props} />
</InputRoot>
);
}
export { NumericInput, NumericInputPrimitive };