Number input
A number field after the spin button pattern, in four looks. It steps, keeps to its limits, repeats while held and reads the number format of the language.
One of 7 components in Pickers and selections
Variants
Details
What Number input brings along, read from the registry and the source.
- Category
- Pickers and selections
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/number-input.tsx
- npm packages
- None
- Languages
- English and German, 4 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The NumberInputProps interface from components/ui/number-input.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.
variantoptionalNumberInputVariantsteppersplitquantityscrub
valueoptionalnumberdefaultValueoptionalnumberonValueChangeoptional(value: number) => voidminoptionalnumbermaxoptionalnumberstepoptionalnumberbigStepoptionalnumberStep for Page Up, Page Down and Shift with an arrow. Default 10 steps.
decimalsoptionalnumberDigits after the decimal mark. Default from the step.
prefixoptionalstringQuiet text before the value, e.g. "$"
unitoptionalstringQuiet text after the value, e.g. "px", "€" or "kg"
groupingoptionalbooleanGroup thousands (1,280). Default true.
handleoptionalstringscrub: a short letter at the start that you drag, e.g. "W". Without handle and handleIcon the label is the handle, inside the box.
handleIconoptionalIconNamescrub: an icon at the start that you drag, instead of the letter
onRemoveoptional() => voidquantity: at the minimum the minus turns into a remove button
sizeoptionalControlSizenameoptionalstringidoptionalstringdisabledoptionalbooleanrequiredoptionalbooleaninvalidoptionalbooleanaria-labeloptionalstringAccessible name when there is no visible label
Built-in texts
Number input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 4 texts
| Key | English | German |
|---|---|---|
| increase | English Increase | German Erhöhen |
| decrease | English Decrease | German Verringern |
| remove | English Remove | German Entfernen |
| scrub | English Drag sideways to change | German Seitlich ziehen zum Ändern |
Accessibility
Read from the source of the component.
- Number input handles the keys Escape, Enter and Space itself.
- Sets aria-controls, aria-describedby, aria-invalid, aria-label, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the role spinbutton.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 440 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { CONTROL_HEIGHT, controlBox, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';
/*
* A number field after the spin button pattern (WAI-ARIA APG): the field
* holds the value, arrows step, Page Up and Page Down take big steps, Home
* and End jump to the limits. Typed text is read in the format of the
* language (1.280,5 or 1,280.5) and snapped to the step on Enter or blur.
* Four looks: buttons stacked at the end, buttons on both sides, a compact
* cart quantity, and a design tool field you drag sideways.
*/
const TEXTS = {
en: { increase: 'Increase', decrease: 'Decrease', remove: 'Remove', scrub: 'Drag sideways to change' },
de: { increase: 'Erhöhen', decrease: 'Verringern', remove: 'Entfernen', scrub: 'Seitlich ziehen zum Ändern' },
};
export type NumberInputVariant = 'stepper' | 'split' | 'quantity' | 'scrub';
export interface NumberInputProps extends FieldOwnProps {
variant?: NumberInputVariant;
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
/** Step for Page Up, Page Down and Shift with an arrow. Default 10 steps. */
bigStep?: number;
/** Digits after the decimal mark. Default from the step. */
decimals?: number;
/** Quiet text before the value, e.g. "$" */
prefix?: string;
/** Quiet text after the value, e.g. "px", "€" or "kg" */
unit?: string;
/** Group thousands (1,280). Default true. */
grouping?: boolean;
/** scrub: a short letter at the start that you drag, e.g. "W". Without handle and handleIcon the label is the handle, inside the box. */
handle?: string;
/** scrub: an icon at the start that you drag, instead of the letter */
handleIcon?: IconName;
/** quantity: at the minimum the minus turns into a remove button */
onRemove?: () => void;
size?: ControlSize;
name?: string;
id?: string;
disabled?: boolean;
required?: boolean;
invalid?: boolean;
/** Accessible name when there is no visible label */
'aria-label'?: string;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const decimalsOf = (n: number) => {
const s = String(n);
return s.includes('.') ? s.split('.')[1].length : 0;
};
/** Reads typed text in the format of the language. Returns null when it is not a number. */
export function parseNumber(text: string, locale: 'en' | 'de'): number | null {
let s = text.replace(/[\s ]/g, '').replace(/[^\d.,+-]/g, '');
if (!s || s === '-' || s === '+') return null;
if (locale === 'de') {
if (s.includes(',')) s = s.replace(/\./g, '').replace(',', '.');
else if (/\.\d{3}(\.|$)/.test(s)) s = s.replace(/\./g, '');
} else s = s.replace(/,/g, '');
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
/** Repeats an action while a button is held: once at once, then faster and faster */
function useHold(action: () => void) {
const timer = useRef<number | undefined>(undefined);
const latest = useRef(action);
latest.current = action;
const stop = () => window.clearTimeout(timer.current);
useEffect(() => stop, []);
const start = (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
latest.current();
let delay = 380;
const tick = () => {
latest.current();
delay = Math.max(40, delay * 0.82);
timer.current = window.setTimeout(tick, delay);
};
timer.current = window.setTimeout(tick, delay);
};
return { onPointerDown: start, onPointerUp: stop, onPointerLeave: stop, onPointerCancel: stop, onContextMenu: (e: React.MouseEvent) => e.preventDefault() };
}
/** The value slides in from the side it came from: up when it grows, down when it shrinks */
function nudge(el: HTMLElement | null, dir: number) {
if (!el || !dir || prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate([{ transform: `translateY(${dir > 0 ? 7 : -7}px)`, opacity: 0.25 }, { transform: 'translateY(0)', opacity: 1 }], { duration: 260, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}
/** A short sideways bump when a limit is reached */
function bump(el: HTMLElement | null) {
if (!el || prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(3px)' }, { transform: 'translateX(-2px)' }, { transform: 'translateX(0)' }], { duration: 240, easing: 'ease-out' });
}
const STEP_BTN =
'relative grid shrink-0 place-items-center text-muted-foreground outline-none transition-[background-color,color,scale] duration-200 ease-[var(--ease-kit)] hover:bg-accent hover:text-foreground active:scale-90 focus-visible:z-10 focus-visible:bg-accent focus-visible:text-foreground focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:pointer-events-none disabled:opacity-35 motion-reduce:transition-none';
function NumberBox(props: Omit<NumberInputProps, keyof FieldOwnProps> & { inlineLabel?: React.ReactNode }) {
const {
variant = 'stepper', value: valueProp, defaultValue, onValueChange, min = -Infinity, max = Infinity, step = 1, bigStep, decimals, prefix, unit,
grouping = true, handle, handleIcon, onRemove, size = 'md', name, id, disabled, required, invalid, labels, className, inlineLabel,
} = props;
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const c = useControl({ id, invalid, disabled, required });
const digits = decimals ?? decimalsOf(step);
const big = bigStep ?? step * 10;
const [inner, setInner] = useState(() => defaultValue ?? (Number.isFinite(min) ? min : 0));
const value = valueProp ?? inner;
const [draft, setDraft] = useState<string | null>(null);
const boxRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const valueRef = useRef<HTMLSpanElement>(null);
const hintId = useId();
useShake(boxRef, c.invalid);
const fmt = (n: number, group = grouping) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits, useGrouping: group });
const snap = (n: number) => {
const base = Number.isFinite(min) ? min : 0;
const snapped = round(base + Math.round((n - base) / step) * step, digits);
return Math.min(max, Math.max(min, snapped));
};
const commit = (next: number, from: 'step' | 'type' = 'step') => {
// Steps land on the grid of the step, typed values are kept as typed (only limits and digits apply)
const v = from === 'type' ? Math.min(max, Math.max(min, round(next, digits))) : snap(next);
if (v === value) {
if (from === 'step' && next !== value) bump(boxRef.current);
return;
}
if (from === 'step') nudge(variant === 'stepper' || variant === 'scrub' ? inputRef.current : valueRef.current ?? inputRef.current, v - value);
setInner(v);
onValueChange?.(v);
};
const by = (delta: number) => commit(value + delta);
const plus = useHold(() => by(step));
const minus = useHold(() => (onRemove && value <= min ? onRemove() : by(-step)));
const atMin = value <= min;
const atMax = value >= max;
const removeMode = variant === 'quantity' && Boolean(onRemove) && atMin;
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const k = e.key;
const map: Record<string, () => void> = {
ArrowUp: () => by(e.shiftKey ? big : step),
ArrowDown: () => by(-(e.shiftKey ? big : step)),
PageUp: () => by(big),
PageDown: () => by(-big),
Home: () => Number.isFinite(min) && commit(min),
End: () => Number.isFinite(max) && commit(max),
};
if (map[k]) {
e.preventDefault();
if (draft !== null) {
const typed = parseNumber(draft, locale);
setDraft(null);
if (typed !== null && snap(typed) !== value) return commit(typed, 'type');
}
map[k]();
} else if (k === 'Enter') {
finish();
} else if (k === 'Escape' && draft !== null) {
e.preventDefault();
setDraft(null);
}
};
const finish = () => {
if (draft === null) return;
const typed = parseNumber(draft, locale);
setDraft(null);
if (typed !== null) commit(typed, 'type');
};
/* ---------- scrub: drag the handle sideways ---------- */
const drag = useRef<{ x: number; start: number; moved: boolean } | null>(null);
const [dragging, setDragging] = useState(false);
const scrub = {
onPointerDown: (e: React.PointerEvent<HTMLSpanElement>) => {
if (c.disabled || e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { x: e.clientX, start: value, moved: false };
setDragging(true);
},
onPointerMove: (e: React.PointerEvent<HTMLSpanElement>) => {
const d = drag.current;
if (!d) return;
const dx = e.clientX - d.x;
if (Math.abs(dx) > 2) d.moved = true;
// One step every 4 px, Shift ten times faster, Alt ten times finer
const factor = e.shiftKey ? 10 : e.altKey ? 0.1 : 1;
const next = snap(d.start + Math.round(dx / 4) * step * factor);
if (next !== value) {
setInner(next);
onValueChange?.(next);
}
},
onPointerUp: () => {
const d = drag.current;
drag.current = null;
setDragging(false);
if (d && !d.moved) inputRef.current?.focus();
},
};
useEffect(() => {
if (!dragging) return;
const prev = document.body.style.cursor;
document.body.style.cursor = 'ew-resize';
return () => void (document.body.style.cursor = prev);
}, [dragging]);
const shown = draft ?? fmt(value);
// "50%" in English, "50 %" in German, degrees always close
const tight = unit === '°' || (unit === '%' && locale === 'en');
const valueText = `${prefix ?? ''}${fmt(value)}${unit ? `${tight ? '' : ' '}${unit}` : ''}`;
const input = (
<input
ref={inputRef}
id={c.id}
type="text"
inputMode={digits > 0 || min < 0 ? 'decimal' : 'numeric'}
role="spinbutton"
aria-valuenow={value}
aria-valuemin={Number.isFinite(min) ? min : undefined}
aria-valuemax={Number.isFinite(max) ? max : undefined}
aria-valuetext={valueText}
aria-label={props['aria-label']}
aria-invalid={c['aria-invalid']}
aria-describedby={cn(c['aria-describedby'], variant === 'scrub' && hintId) || undefined}
disabled={c.disabled}
required={c.required}
autoComplete="off"
spellCheck={false}
value={shown}
onChange={(e) => setDraft(e.target.value)}
onFocus={(e) => {
setDraft(fmt(value, false));
const el = e.currentTarget;
requestAnimationFrame(() => el.select());
}}
onBlur={finish}
onKeyDown={onKeyDown}
// In the centered looks the field is as wide as its text, so a unit sits right next to the number
style={variant === 'split' || variant === 'quantity' ? { width: `${Math.max(shown.length, 1) + 0.6}ch` } : undefined}
className={cn(
'h-full min-w-0 bg-transparent tabular-nums text-inherit outline-none placeholder:text-subtle-foreground',
// Between two buttons the box ring alone does not say which part has focus, so the number gets its own line
variant === 'split' || variant === 'quantity' ? 'max-w-full flex-none rounded-none text-center font-medium focus-visible:shadow-[inset_0_-2px_0_var(--primary)]' : 'w-0 flex-1 text-start',
)}
/>
);
const hidden = name ? <input type="hidden" name={name} value={value} /> : null;
const affix = (text: string | undefined, side: 'start' | 'end') =>
text ? <span aria-hidden className={cn('shrink-0 select-none text-subtle-foreground', side === 'start' ? 'pe-1' : tight ? 'ps-px' : 'ps-1')}>{text}</span> : null;
/* ---------- split: minus | value | plus ---------- */
if (variant === 'split' || variant === 'quantity') {
const q = variant === 'quantity';
const btn = q ? 'size-11 sm:size-9 rounded-[7px]' : cn('h-full aspect-square', size === 'lg' ? 'w-12' : 'w-11');
return (
<div
ref={boxRef}
data-invalid={c.invalid || undefined}
data-disabled={c.disabled || undefined}
className={cn(
'group/num relative flex items-center',
q ? 'h-11 w-[8.5rem] shrink-0 gap-0.5 rounded-lg p-0 text-base sm:h-9 sm:w-[7.25rem] sm:text-sm' : cn('w-full', CONTROL_HEIGHT[size]),
controlBox,
q && 'overflow-hidden',
className,
)}
>
<button
type="button"
aria-label={removeMode ? t.remove : t.decrease}
aria-controls={c.id}
disabled={c.disabled || (atMin && !removeMode)}
{...minus}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (removeMode) onRemove?.();
else by(-step);
}
}}
className={cn(STEP_BTN, btn, !q && 'rounded-s-[inherit] border-e border-border', removeMode && 'hover:text-destructive')}
>
{/* The minus turns into a bin at the minimum, with a short turn */}
<span key={removeMode ? 'rm' : 'minus'} className="grid place-items-center transition-[rotate,scale,opacity] duration-300 ease-[var(--ease-kit)] starting:-rotate-90 starting:scale-50 starting:opacity-0 motion-reduce:transition-none">
<Icon name={removeMode ? 'trash' : 'minus'} size={q ? 15 : 17} />
</span>
</button>
<span ref={valueRef} onClick={() => inputRef.current?.focus()} className="flex h-full min-w-0 flex-1 cursor-text items-center justify-center px-1">
{affix(prefix, 'start')}
{input}
{affix(unit, 'end')}
</span>
<button
type="button"
aria-label={t.increase}
aria-controls={c.id}
disabled={c.disabled || atMax}
{...plus}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
by(step);
}
}}
className={cn(STEP_BTN, btn, !q && 'rounded-e-[inherit] border-s border-border')}
>
<Icon name="plus" size={q ? 15 : 17} />
</button>
{hidden}
</div>
);
}
/* ---------- scrub: a design tool field ---------- */
if (variant === 'scrub') {
const ranged = Number.isFinite(min) && Number.isFinite(max);
const p = ranged ? (value - min) / (max - min) : 0;
return (
<div
ref={boxRef}
data-invalid={c.invalid || undefined}
data-disabled={c.disabled || undefined}
data-dragging={dragging || undefined}
className={cn('group/num relative flex h-11 w-full items-center overflow-hidden rounded-lg text-base sm:h-9 sm:rounded-md sm:text-[13px]', controlBox, dragging && 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))]', className)}
>
{inlineLabel ? (
// A word as the handle: it is the label of the field too
<label
htmlFor={c.id}
title={t.scrub}
{...scrub}
className={cn(
'flex h-full shrink-0 cursor-ew-resize touch-none select-none items-center ps-3 pe-2 text-[13px] text-muted-foreground transition-colors duration-200 hover:text-foreground sm:ps-2.5 sm:text-[12.5px]',
dragging && 'text-primary',
c.disabled && 'pointer-events-none',
)}
>
{inlineLabel}
</label>
) : (
<span
aria-hidden
title={t.scrub}
{...scrub}
className={cn(
'grid h-full w-11 shrink-0 cursor-ew-resize touch-none select-none place-items-center text-[12px] font-medium text-subtle-foreground transition-colors duration-200 hover:text-foreground sm:w-8',
dragging && 'text-primary',
c.disabled && 'pointer-events-none',
)}
>
{handleIcon ? <Icon name={handleIcon} size={15} /> : handle}
</span>
)}
<span className={cn('flex h-full min-w-0 flex-1 items-center pe-2.5', inlineLabel && '[&>input]:text-end')}>
{affix(prefix, 'start')}
{input}
{affix(unit, 'end')}
</span>
{/* Where the value sits in its range, a hairline along the bottom edge */}
{ranged && (
<span aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-[2px] overflow-hidden">
<span
className={cn('block h-full origin-left bg-primary transition-[scale,opacity] duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none', dragging ? 'opacity-100' : 'opacity-0 group-hover/num:opacity-60 group-focus-within/num:opacity-60')}
style={{ scale: `${round(p, 4)} 1` }}
/>
</span>
)}
<span id={hintId} className="sr-only">{t.scrub}</span>
{hidden}
</div>
);
}
/* ---------- stepper: buttons stacked at the end ---------- */
return (
<div
ref={boxRef}
data-invalid={c.invalid || undefined}
data-disabled={c.disabled || undefined}
className={cn('group/num relative flex w-full items-stretch overflow-hidden', CONTROL_HEIGHT[size], controlBox, className)}
>
<span className={cn('flex min-w-0 flex-1 items-center', size === 'sm' ? 'px-3' : size === 'lg' ? 'px-4' : 'px-3.5')}>
{affix(prefix, 'start')}
{input}
{affix(unit, 'end')}
</span>
{/* Phones get two wide buttons side by side, larger screens a slim column */}
<span className="flex shrink-0 border-s border-border sm:w-8 sm:flex-col">
<button type="button" tabIndex={-1} aria-label={t.increase} aria-controls={c.id} disabled={c.disabled || atMax} {...plus} className={cn(STEP_BTN, 'order-2 w-11 border-s border-border sm:order-1 sm:w-full sm:flex-1 sm:border-s-0 sm:border-b')}>
<Icon name="plus" size={15} className="sm:hidden" />
<Icon name="chevron-up" size={13} strokeWidth={2} className="max-sm:hidden" />
</button>
<button type="button" tabIndex={-1} aria-label={t.decrease} aria-controls={c.id} disabled={c.disabled || atMin} {...minus} className={cn(STEP_BTN, 'order-1 w-11 sm:order-2 sm:w-full sm:flex-1')}>
<Icon name="minus" size={15} className="sm:hidden" />
<Icon name="chevron-down" size={13} strokeWidth={2} className="max-sm:hidden" />
</button>
</span>
{hidden}
</div>
);
}
/**
* A number field with steps, limits and the number format of the language.
* <NumberInput label="Copies" min={1} max={500} defaultValue={50} unit="pcs" />
* <NumberInput variant="split" label="Adults" min={1} max={8} defaultValue={2} />
* <NumberInput variant="quantity" defaultValue={1} min={1} onRemove={() => remove(id)} aria-label="Quantity" />
* <NumberInput variant="scrub" handle="W" unit="px" min={0} max={1440} defaultValue={320} aria-label="Width" />
*/
export function NumberInput({ label, hint, error, optional, labelAction, className, ...rest }: NumberInputProps) {
// scrub without a letter or icon: the label itself is the handle, inside the box
if (rest.variant === 'scrub' && label && !rest.handle && !rest.handleIcon) {
const box = <NumberBox {...rest} inlineLabel={label} invalid={rest.invalid ?? (error ? true : undefined)} className={hint || error ? undefined : className} />;
return withField({ hint, error, id: rest.id, required: rest.required, disabled: rest.disabled, className }, box);
}
const wrapped = Boolean(label || hint || error);
return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <NumberBox {...rest} invalid={rest.invalid ?? (error ? true : undefined)} className={wrapped ? undefined : className} />);
}Frequent questions
How do I install Number input?
With aiw add @ai-website/number-input. The command puts components/ui/number-input.tsx into your project, the code is yours from then on. It brings along icon and input. It needs no further npm packages.
Which variants does Number input have?
4 variants, Stepper, Split, Cart quantity and Scrub. You pick one with the variant prop, for example variant="quantity".
Can I translate the texts of Number input?
Yes. Number input carries 4 texts of its own in English and German, such as “Increase”, “Decrease” and “Remove”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Number input?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Number input with aiw_components and install it with aiw_add @ai-website/number-input. Name the variant in your prompt, for example variant="split", and the assistant writes your content straight into the props.
Can I adjust Number input without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching NumberInput code to copy.