Number Input
An editable numeric input with stepper buttons, keyboard support, and pluggable formatting for locale-aware display.
import { useState } from 'react';
import { NumberInput } from '@/components/number-input';
export default function NumberInputExample() {
const [value, setValue] = useState(0);
return (
<div className="w-48">
<NumberInput value={value} onValueChange={setValue} />
</div>
);
} Dependencies
Source Code
import { MinusIcon, PlusIcon } from '@phosphor-icons/react/dist/ssr';
import {
createContext,
use,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Slot } from '@/components/slot';
import {
Input,
type InputSize,
type InputVariant,
} from '@/components/input';
import { cn, cva } from '@/lib/utils/classnames';
interface NumberInputContextValue {
value: number;
/** Commit a new value through clamp + step rounding. Returns the committed value. */
setValue: (next: number) => number;
increment: (multiplier?: number) => number;
decrement: (multiplier?: number) => number;
min: number;
max: number;
step: number;
disabled: boolean;
size: InputSize;
format: (n: number) => string;
parse: (s: string) => number;
}
const NumberInputContext = createContext<NumberInputContextValue | null>(null);
const useNumberInputContext = () => {
const ctx = use(NumberInputContext);
if (!ctx) {
throw new Error(
'NumberInput components must be used within a <NumberInput />'
);
}
return ctx;
};
const stepPrecision = (step: number): number => {
const s = String(step);
const i = s.indexOf('.');
return i === -1 ? 0 : s.length - i - 1;
};
const round = (n: number, precision: number): number => {
const f = 10 ** precision;
return Math.round(n * f) / f;
};
const clamp = (n: number, min: number, max: number): number =>
Math.min(Math.max(n, min), max);
// Default parser: empty string is invalid (triggers revert), otherwise pass to
// Number — which accepts integers, decimals, scientific notation, and signs.
// Locale-aware parsing is up to the consumer via the `parse` prop.
const defaultParse = (s: string): number => {
const trimmed = s.trim();
if (trimmed === '') return Number.NaN;
return Number(trimmed);
};
interface NumberInputProps
extends Omit<React.ComponentPropsWithRef<'div'>, 'onChange' | 'children'> {
/** Controlled value. */
value?: number;
/** Default value (uncontrolled). Defaults to `min` if finite, otherwise 0. */
defaultValue?: number;
/** Called when the value commits (blur / Enter / stepper / keyboard). */
onValueChange?: (value: number) => void;
/** Lower bound (inclusive). Defaults to `-Infinity`. */
min?: number;
/** Upper bound (inclusive). Defaults to `+Infinity`. */
max?: number;
/** Increment step. Defaults to 1. */
step?: number;
disabled?: boolean;
/** Forwarded to the underlying `Input.Group`. Defaults to `md`. */
size?: InputSize;
/** Forwarded to the underlying `Input.Group`. Defaults to `default`. */
variant?: InputVariant;
/** Format the committed value for display. Defaults to `String`. */
format?: (n: number) => string;
/**
* Parse the user's typed value. Return `NaN` to reject (the field reverts to
* the last committed value on blur). Defaults to a `Number`-based parser
* that treats empty input as invalid.
*/
parse?: (s: string) => number;
/**
* Defaults to `<NumberInput.Decrement /> <NumberInput.Field /> <NumberInput.Increment />`.
* Pass children to customize the layout (e.g. swap orders, drop a stepper, wrap with Field.Control).
*/
children?: React.ReactNode;
}
const NumberInput = ({
value: propsValue,
defaultValue,
onValueChange,
min = Number.NEGATIVE_INFINITY,
max = Number.POSITIVE_INFINITY,
step = 1,
disabled = false,
size = 'md',
variant = 'default',
format = String,
parse = defaultParse,
className,
children,
...rest
}: NumberInputProps) => {
const isControlled = propsValue !== undefined;
const precision = stepPrecision(step);
const [internalValue, setInternalValue] = useState<number>(() => {
const initial = defaultValue ?? (Number.isFinite(min) ? min : 0);
return round(clamp(initial, min, max), precision);
});
const value = isControlled ? propsValue : internalValue;
const setValue = useCallback(
(next: number): number => {
const committed = round(clamp(next, min, max), precision);
if (!isControlled) setInternalValue(committed);
onValueChange?.(committed);
return committed;
},
[isControlled, min, max, precision, onValueChange]
);
const increment = useCallback(
(multiplier = 1) => setValue(value + step * multiplier),
[value, step, setValue]
);
const decrement = useCallback(
(multiplier = 1) => setValue(value - step * multiplier),
[value, step, setValue]
);
const ctx = useMemo<NumberInputContextValue>(
() => ({
value,
setValue,
increment,
decrement,
min,
max,
step,
disabled,
size,
format,
parse,
}),
[
value,
setValue,
increment,
decrement,
min,
max,
step,
disabled,
size,
format,
parse,
]
);
return (
<NumberInputContext value={ctx}>
<Input.Group
size={size}
variant={variant}
className={className}
{...rest}
>
{children ?? (
<>
<NumberInputDecrement />
<NumberInputField />
<NumberInputIncrement />
</>
)}
</Input.Group>
</NumberInputContext>
);
};
interface NumberInputFieldProps
extends Omit<
React.ComponentPropsWithRef<typeof Input>,
'value' | 'defaultValue' | 'onChange' | 'type' | 'disabled'
> {}
const NumberInputField = ({
ref,
className,
onBlur,
onKeyDown,
...props
}: NumberInputFieldProps) => {
const { value, setValue, min, max, step, disabled, format, parse } =
useNumberInputContext();
const [draft, setDraft] = useState<string>(() => format(value));
// Tracks whether the user has typed since the last commit. Gates the sync
// effect below so an inline `format` arrow on a parent re-render can't stomp
// typed-but-uncommitted text. Reset by every commit / step / jump path.
const dirtyRef = useRef(false);
// Mirror the committed value into the displayed draft. We can't simply
// depend on `value` alone — `format` may also need to re-run on a locale
// switch — but we must skip the sync while the user is mid-edit.
useEffect(() => {
if (!dirtyRef.current) setDraft(format(value));
}, [value, format]);
const stepBy = (multiplier: number) => {
const parsed = parse(draft);
const base = Number.isFinite(parsed) ? parsed : value;
const committed = setValue(base + step * multiplier);
setDraft(format(committed));
dirtyRef.current = false;
};
const jumpTo = (target: number) => {
const committed = setValue(target);
setDraft(format(committed));
dirtyRef.current = false;
};
const commit = () => {
const parsed = parse(draft);
if (Number.isFinite(parsed)) {
const committed = setValue(parsed);
setDraft(format(committed));
} else {
setDraft(format(value));
}
dirtyRef.current = false;
};
return (
<Input
ref={ref}
type="text"
inputMode="decimal"
role="spinbutton"
aria-valuenow={value}
aria-valuetext={format(value)}
aria-valuemin={Number.isFinite(min) ? min : undefined}
aria-valuemax={Number.isFinite(max) ? max : undefined}
value={draft}
disabled={disabled}
onChange={(e) => {
setDraft(e.target.value);
dirtyRef.current = true;
}}
onBlur={(e) => {
commit();
onBlur?.(e);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
if (e.key === 'Enter') {
commit();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
stepBy(e.shiftKey ? 10 : 1);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
stepBy(e.shiftKey ? -10 : -1);
return;
}
if (e.key === 'PageUp') {
e.preventDefault();
stepBy(10);
return;
}
if (e.key === 'PageDown') {
e.preventDefault();
stepBy(-10);
return;
}
if (e.key === 'Home' && Number.isFinite(min)) {
e.preventDefault();
jumpTo(min);
return;
}
if (e.key === 'End' && Number.isFinite(max)) {
e.preventDefault();
jumpTo(max);
}
}}
className={cn('text-center tabular-nums', className)}
{...props}
/>
);
};
// xs/sm groups have no `py`, so the stepper can't inset vertically — it runs
// flush and side-rounds (`rounded-l-lg` / `rounded-r-lg`) to trace the group's
// outer curve at the edge. md/lg groups have `py-1`, so the stepper insets
// horizontally with `mx-(--inset)` and rounds with the next-smaller token —
// `rounded-{lg,xl}`. The radius scale is calibrated so each step equals
// `--inset` (`--radius * 2`), keeping corners concentric across the dial.
const stepperStyle = cva({
base: 'focus-visible:ring-(length:--ring-width) inline-flex aspect-square h-full shrink-0 cursor-pointer items-center justify-center self-stretch text-foreground-secondary outline-none ring-ring transition-colors hover:bg-foreground/5 hover:text-foreground disabled:pointer-events-none disabled:opacity-40',
variants: {
size: {
xs: '',
sm: '',
md: 'mx-(--inset) rounded-lg',
lg: 'mx-(--inset) rounded-xl',
},
side: {
decrement: '',
increment: '',
},
},
compoundVariants: [
{ size: 'xs', side: 'decrement', class: 'rounded-l-lg' },
{ size: 'xs', side: 'increment', class: 'rounded-r-lg' },
{ size: 'sm', side: 'decrement', class: 'rounded-l-lg' },
{ size: 'sm', side: 'increment', class: 'rounded-r-lg' },
],
defaultVariants: {
size: 'md',
side: 'decrement',
},
});
interface NumberInputStepperProps
extends React.ComponentPropsWithRef<'button'> {
asChild?: boolean;
}
const NumberInputDecrement = ({
ref,
asChild,
className,
onClick,
disabled: propDisabled,
'aria-label': ariaLabel = 'Decrement',
children,
...props
}: NumberInputStepperProps) => {
const { decrement, value, min, disabled, size } = useNumberInputContext();
const isAtMin = Number.isFinite(min) && value <= min;
const isDisabled = propDisabled || disabled || isAtMin;
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
type={asChild ? undefined : 'button'}
aria-label={ariaLabel}
disabled={isDisabled}
onClick={(e) => {
onClick?.(e);
if (e.defaultPrevented) return;
decrement();
}}
className={cn(
!asChild && stepperStyle({ size, side: 'decrement' }),
className
)}
{...props}
>
{children ?? <MinusIcon className="size-4" />}
</Comp>
);
};
const NumberInputIncrement = ({
ref,
asChild,
className,
onClick,
disabled: propDisabled,
'aria-label': ariaLabel = 'Increment',
children,
...props
}: NumberInputStepperProps) => {
const { increment, value, max, disabled, size } = useNumberInputContext();
const isAtMax = Number.isFinite(max) && value >= max;
const isDisabled = propDisabled || disabled || isAtMax;
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
type={asChild ? undefined : 'button'}
aria-label={ariaLabel}
disabled={isDisabled}
onClick={(e) => {
onClick?.(e);
if (e.defaultPrevented) return;
increment();
}}
className={cn(
!asChild && stepperStyle({ size, side: 'increment' }),
className
)}
{...props}
>
{children ?? <PlusIcon className="size-4" />}
</Comp>
);
};
const CompoundNumberInput = Object.assign(NumberInput, {
Field: NumberInputField,
Decrement: NumberInputDecrement,
Increment: NumberInputIncrement,
});
export type { NumberInputProps };
export { CompoundNumberInput as NumberInput, useNumberInputContext }; Anatomy
// Default — root renders an Input.Group with steppers + field.
<NumberInput value={n} onValueChange={setN} min={0} max={100} step={1} />
// Custom layout — pass children to override.
<NumberInput value={n} onValueChange={setN}>
<NumberInput.Decrement />
<NumberInput.Field />
<NumberInput.Increment />
</NumberInput>
The root wraps everything in Input.Group and provides a default layout. Pass children when you need a different arrangement — drop a stepper, wrap the field with Field.Control, or splice in custom addons.
API Reference
NumberInput
| Prop | Default | Type | Description |
|---|---|---|---|
value | - | number | Controlled value. |
defaultValue | - | number | Default value (uncontrolled). Defaults to `min` if finite, otherwise `0`. |
onValueChange | - | (value: number) => void | Called when the value commits — on blur, Enter, stepper click, or keyboard step. |
min | -Infinity | number | Lower bound, inclusive. Steppers disable at the bound only when finite. |
max | +Infinity | number | Upper bound, inclusive. |
step | 1 | number | Increment step. Determines display precision (e.g. `step={0.01}` rounds to 2 decimals). |
disabled | false | boolean | |
size | "md" | "xs""sm""md""lg" | Forwarded to the underlying `Input.Group`. |
variant | "default" | "default""minimal" | Forwarded to the underlying `Input.Group`. |
format | String | (n: number) => string | Format the committed value for display. |
parse | - | (s: string) => number | Parse the user's typed value. Return `NaN` to reject (the field reverts on blur). Defaults to a `Number`-based parser that treats empty input as invalid. |
NumberInput.Field
Extends Input. Renders an <input type="text" inputMode="decimal" role="spinbutton"> wired to the context. value, defaultValue, onChange, type, and disabled are owned by the root and can’t be overridden here.
NumberInput.Decrement / NumberInput.Increment
Extend the button element. Auto-disable when the value reaches a finite min / max. Default to a Phosphor Minus / Plus icon — pass children to override, or asChild to swap the entire element.
Behavior
Typing is unrestricted
The field accepts any input — digits, signs, ., ,, e, even garbage like abc. There is no per-keystroke filtering. The user’s input is parsed only on commit (blur or Enter):
- If
parsereturns a finite number, the value clamps to[min, max], rounds tostepprecision, and commits. - If
parsereturnsNaN, the field reverts to the last committed value.
This matches the spirit of native <input type="number"> (which also accepts e for scientific notation) and avoids fighting the user mid-edit.
Keyboard
| Key | Action |
|---|---|
↑ / ↓ | Step by ±step |
Shift + ↑/↓ | Step by ±step × 10 |
Page Up/Down | Step by ±step × 10 |
Home | Jump to min (if finite) |
End | Jump to max (if finite) |
Enter | Commit the current draft |
Bounded vs unbounded
min and max default to ±Infinity. Bounds only become enforced (and steppers disable) when finite. Pass any combination — bounded above, bounded below, or both.
0–100, step 5. Steppers disable at the bounds.
import { useState } from 'react';
import { NumberInput } from '@/components/number-input';
export default function NumberInputBoundedExample() {
const [value, setValue] = useState(50);
return (
<div className="flex w-56 flex-col gap-2">
<NumberInput
value={value}
onValueChange={setValue}
min={0}
max={100}
step={5}
/>
<p className="text-foreground-secondary text-xs">
0–100, step 5. Steppers disable at the bounds.
</p>
</div>
);
} Sizes
import { useState } from 'react';
import { NumberInput } from '@/components/number-input';
const sizes = ['xs', 'sm', 'md', 'lg'] as const;
export default function NumberInputSizesExample() {
const [value, setValue] = useState(0);
return (
<div className="flex w-56 flex-col gap-3">
{sizes.map((size) => (
<NumberInput
key={size}
size={size}
value={value}
onValueChange={setValue}
min={0}
max={100}
/>
))}
</div>
);
} Inside a Field
Field.Control wraps a single form control. Place it around NumberInput.Field, not the root — the root has no DOM and the steppers aren’t form controls.
Between 1 and 99. Use ↑/↓ to step, Shift for ×10.
import { useState } from 'react';
import { Field } from '@/components/field';
import { NumberInput } from '@/components/number-input';
export default function NumberInputFormExample() {
const [quantity, setQuantity] = useState(1);
return (
<div className="w-64">
<Field>
<Field.Label>Quantity</Field.Label>
<NumberInput
value={quantity}
onValueChange={setQuantity}
min={1}
max={99}
>
<NumberInput.Decrement />
<Field.Control>
<NumberInput.Field />
</Field.Control>
<NumberInput.Increment />
</NumberInput>
<Field.Description>
Between 1 and 99. Use ↑/↓ to step, Shift for ×10.
</Field.Description>
</Field>
</div>
);
} Locale-aware formatting
Pass format and parse to swap how the value is shown and how user input is interpreted. Anything goes — Intl.NumberFormat, custom precision, currency, thousands separators. This example uses de-DE formatting (1.234,56):
import { useMemo, useState } from 'react';
import { NumberInput } from '@/components/number-input';
// German locale formatting: `1.234,56` instead of `1,234.56`. The same pattern
// works for any locale via `Intl.NumberFormat`.
export default function NumberInputLocaleExample() {
const [value, setValue] = useState(1234.56);
const { format, parse } = useMemo(() => {
const formatter = new Intl.NumberFormat('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return {
format: (n: number) => formatter.format(n),
parse: (s: string) => {
const trimmed = s.trim();
if (trimmed === '') return Number.NaN;
// Strip thousands separators (`.`), normalize decimal `,` to `.`.
const normalized = trimmed.replace(/\./g, '').replace(',', '.');
return Number(normalized);
},
};
}, []);
return (
<div className="w-64">
<NumberInput
value={value}
onValueChange={setValue}
step={0.01}
format={format}
parse={parse}
/>
</div>
);
}
const formatter = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
<NumberInput
format={(n) => formatter.format(n)}
parse={(s) => Number(s.replace(/\./g, "").replace(",", "."))}
// ...
/>;
Async sync
Common shape for ecommerce-style flows: commit locally on every change so the UI feels instant, debounce the server call in the background, and surface a status indicator as a sibling of the input. Keep the field interactive throughout — disabling mid-sync makes a snappy interaction feel laggy. Avoid placing the indicator inside the input next to a centered value; it competes with the optical balance.
import { useEffect, useMemo, useRef, useState } from 'react';
import { NumberInput } from '@/components/number-input';
import { Spinner } from '@/components/spinner';
import { debounce } from '@/lib/debounce';
import { cn } from '@/lib/utils/classnames';
// Cart-style quantity selector: optimistic local update on every commit,
// debounced server sync in the background. The field stays interactive while
// in-flight — disabling mid-sync would feel laggy. The status indicator lives
// outside the input so the field's centered value stays optically balanced.
export default function NumberInputAsyncExample() {
const [quantity, setQuantity] = useState(1);
const [syncing, setSyncing] = useState(false);
const inFlight = useRef(0);
const sync = useMemo(
() =>
debounce(async (_next: number) => {
const id = ++inFlight.current;
setSyncing(true);
// Pretend to hit a server.
await new Promise((r) => setTimeout(r, 800));
// Only clear if this is the latest call.
if (id === inFlight.current) setSyncing(false);
}, 400),
[]
);
useEffect(() => {
sync(quantity);
}, [quantity, sync]);
return (
<div className="flex items-center gap-3">
<div className="w-48">
<NumberInput
value={quantity}
onValueChange={setQuantity}
min={1}
max={99}
/>
</div>
<Spinner
size="sm"
className={cn(
'transition-opacity',
syncing ? 'opacity-100' : 'opacity-0'
)}
/>
</div>
);
} Best Practices
- Don’t fight the user. No keystroke filtering, no auto-format mid-edit. Validate on blur. If a user wants to paste
1e3, let them — it’s a valid number. - Use
stepfor precision, not decoration. Settingstep={0.01}doesn’t just constrain the steppers — it also rounds committed values to 2 decimals. Pick a step that reflects the precision you actually want stored. - Bounded inputs deserve labels. When
min/maxare enforced, communicate them: aField.Description(“Between 1 and 99”) goes a long way for users who don’t notice the steppers disabling. - Accessibility:
- The field renders
role="spinbutton"witharia-valuenow,aria-valuetext, and (when finite)aria-valuemin/aria-valuemax. aria-valuetextis the formatted string ("1.234,56"), so screen readers announce the displayed value, not the raw number.- Steppers ship with sensible default
aria-labels — override per locale.
- The field renders
Previous
Modal
Next
OTP Input