Number Input
Constrained numeric entry with steppers, clamping and locale formatting — and no scroll-wheel surprises.
Also called Spin Button, Numeric Stepper, Quantity Input, Currency Input — in this system all of them are Number Input.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Currency
The symbol is a prefix, not part of the value. Alignment is right, figures are tabular, and the decimal places are fixed on blur so a column of amounts lines up.
Clamp on blur
The field accepts an out-of-range value while the user types and corrects it when they leave. Clamping per keystroke makes typing "25" into a max-12 field impossible.
Units belong in the field
A suffix inside the control removes the ambiguity that a label alone leaves. "Timeout: 30" is seconds or milliseconds depending on who is reading.
Sizes
The steppers keep a fixed width across sizes — they are targets, not glyphs, and shrinking them below 20px makes ±1 a game of accuracy.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Between 1 and 12.
A right-aligned tabular value, a unit suffix, and stacked steppers that disable at the bounds rather than disappearing.
- Field width7–10rem
Sized to the widest expected number plus the steppers. A number field stretched to the form width claims a magnitude it will never hold.
- AlignmentRight, tabular figures
Units line up vertically in a column of fields, so magnitude becomes readable as shape. Tabular figures stop the value shifting as digits change.
- Steppers20 × 16px, stacked
Stacked rather than flanking, so the field stays compact and the value keeps its full width. Below 20px, ±1 becomes a test of accuracy.
- Bound behaviourDisabled, never hidden
A stepper that vanishes at the limit shifts the layout and removes the only signal that a limit exists.
- Unit suffixMuted, inside the field
Part of the control, not the value. It removes the ambiguity a label alone leaves — 30 seconds or 30 milliseconds.
- StepMatches how people think
1 for replicas, 5 for percentages, 100 for a budget. If a common value takes more than five presses, the step is wrong.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-inset | — | Field fill |
| --ds-border-interactive | — | Idle border |
| --ds-accent | — | Focus border |
| --ds-accent-subtle | — | Focus halo |
| --ds-fg | — | The value |
| --ds-fg-muted | — | Unit suffix and stepper glyphs |
| --ds-fg-disabled | — | A stepper at its bound |
| --ds-danger-border | — | Out-of-range border |
Spacing
| Token | Value | Used for |
|---|---|---|
| padding | Reduced on the stepper side |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Field corners |
Typography
| Token | Value | Used for |
|---|---|---|
| tabular-nums | — | The value, so digits do not shift width |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Stepper hover and press |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Type | Min width | Touch target | When to use |
|---|---|---|---|---|---|---|
| Small | 32px | 0 6px 0 10px | 13px | 5.5rem | — | Table cells and dense filter bars. |
| Medium | 36px | 0 8px 0 12px | 15px | 7rem | — | The default. |
| Large | 44px | 0 10px 0 14px | 16px | 8rem | — | Touch-first layouts and checkout quantities. |
| Stepper | 16px | — | — | 20px | 44px combined on coarse pointers | Stacked. Disabled at the bounds, never removed. |
| Currency | — | — | — | 9rem | — | Extra room for the symbol prefix and two decimal places. |
onBlur → clamp(value, min, max)
✗ onChange → clamp(…)type="text" inputmode="decimal"
pattern="[0-9]*"Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Stepper glyphs at 12px owe 4.5:1 — they are small and they are the control.
- A stepper disabled at its bound may use the disabled tone, and the bound must also be conveyed by aria-valuemin/max rather than by colour alone.
- The unit suffix is content and owes 4.5:1. It is frequently the only thing telling the user what the number means.
Keyboard
| ↑ / ↓ | Increments and decrements by step. The native behaviour of a spinbutton, and users expect it. |
| Page Up / Page Down | Steps by a larger amount — ten steps by convention. Free to add and invaluable on a wide range. |
| Home / End | Jumps to min or max when both are defined. |
| Tab | Leaves the field. The steppers are not separate tab stops — arrows already do their job. |
| Scroll wheel | Nothing. Must be explicitly suppressed, or a page scroll changes a focused field. |
Screen readers
- The field announces as "Replicas, spin button, 3, minimum 1, maximum 12". That single announcement is where the range comes from.
- Use aria-valuetext whenever units or formatting matter, or "2400" is read as a bare number rather than a budget.
- Announce a clamp when it happens: "Adjusted to the maximum of 12". Silently correcting a value the user typed is the most confusing thing this control can do.
Focus & touch
- Focus stays on the field when a stepper is pressed — the steppers act on the field, they do not take focus from it. The focus halo must be visible around the whole control, including the stepper column.
- Steppers need a 44px combined target on coarse pointers, which usually means widening the column rather than growing the glyphs. Set inputmode so the numeric keypad appears — a full keyboard for a field that only accepts digits is a small insult repeated on every use. Quantity steppers in a cart are one of the few places large flanking +/− buttons beat the stacked layout.
| Attribute | Applied to | Notes |
|---|---|---|
| role="spinbutton" | The field | Implicit on input[type=number]; explicit when using type="text" with inputmode. |
| aria-valuenow / valuemin / valuemax | The field | How the range reaches a screen-reader user. Disabled steppers do not communicate bounds on their own. |
| aria-valuetext | The field | For values that need units or formatting: "30 seconds", "$2,400". A bare "30" is ambiguous read aloud. |
| aria-label | Each stepper | "Increase replicas" / "Decrease replicas". A bare "+" is not a name. |
| aria-hidden | The steppers | A defensible alternative — arrow keys already provide the function, and hiding them removes two redundant stops from the screen-reader path. |
| inputmode="decimal" | The field | Gives a numeric keypad on mobile without inheriting type="number"’s behaviour. |
Example usage
1import { Field, NumberInput } from '@/ui/Input'23<Field label="Replicas" description="Between 1 and 12.">4 <NumberInput5 value={replicas}6 onValueChange={setReplicas}7 min={1}8 max={12}9 step={1}10 suffix="pods"11 />12</Field>1314// type="number" is a trap: it accepts 'e', '+' and '-', returns '' for15// anything it dislikes — so you cannot tell empty from garbage — and steps16// on scroll. This is the version that behaves.17function NumberField({ value, onValueChange, min, max, step = 1 }) {18 const [draft, setDraft] = React.useState(String(value ?? ''))1920 return (21 <input22 type="text"23 inputMode="decimal"24 role="spinbutton"25 aria-valuenow={value}26 aria-valuemin={min}27 aria-valuemax={max}28 value={draft}29 onChange={(e) => setDraft(e.target.value)} // no clamping here30 // Clamp on blur. Typing "25" into a max-12 field must pass through "2".31 onBlur={() => {32 const n = Number(draft)33 if (Number.isNaN(n)) return setDraft(String(value ?? ''))34 const clamped = Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n))35 onValueChange(clamped)36 setDraft(String(clamped))37 if (clamped !== n) announce(`Adjusted to ${clamped}`)38 }}39 // A page scroll must never change a focused value.40 onWheel={(e) => e.currentTarget.blur()}41 onKeyDown={(e) => {42 if (e.key === 'ArrowUp') { e.preventDefault(); nudge(+step) }43 if (e.key === 'ArrowDown') { e.preventDefault(); nudge(-step) }44 if (e.key === 'PageUp') { e.preventDefault(); nudge(+step * 10) }45 if (e.key === 'PageDown') { e.preventDefault(); nudge(-step * 10) }46 }}47 />48 )49}Framework-free HTML
<div class="ds-field">
<label for="replicas">Replicas</label>
<p id="replicas-desc">Between 1 and 12.</p>
<div class="ds-number">
<input
id="replicas"
type="text"
inputmode="decimal"
role="spinbutton"
aria-valuenow="3"
aria-valuemin="1"
aria-valuemax="12"
aria-valuetext="3 pods"
aria-describedby="replicas-desc"
value="3"
/>
<span class="ds-number__unit" aria-hidden="true">pods</span>
<span class="ds-number__steppers">
<!-- Disabled at the bound, never removed: it is the only signal
that a limit exists. -->
<button type="button" aria-label="Increase replicas">▲</button>
<button type="button" aria-label="Decrease replicas" disabled>▼</button>
</span>
</div>
</div>CSS
.ds-number {
display: inline-flex;
align-items: center;
inline-size: 7rem; /* width hints at magnitude */
block-size: 36px;
padding-inline: 12px 8px; /* reduced on the stepper side */
border: 1px solid var(--ds-border-interactive);
border-radius: var(--radius-md);
background: var(--ds-surface-inset);
}
.ds-number input {
inline-size: 100%;
text-align: end; /* a column of numbers lines up */
font-variant-numeric: tabular-nums;
background: none;
border: 0;
}
/* Kill the native spinners: we draw our own so they can be sized as targets
and disabled at the bounds. */
.ds-number input::-webkit-outer-spin-button,
.ds-number input::-webkit-inner-spin-button { appearance: none; margin: 0; }
.ds-number input[type='number'] { -moz-appearance: textfield; }
.ds-number__steppers {
display: grid;
grid-template-rows: 1fr 1fr;
inline-size: 20px;
margin-inline-start: 6px;
}
.ds-number__steppers button { block-size: 16px; color: var(--ds-fg-muted); }
.ds-number__steppers button:disabled { color: var(--ds-fg-disabled); }
@media (pointer: coarse) {
.ds-number__steppers { inline-size: 44px; }
.ds-number__steppers button { block-size: 22px; }
}Component API
NumberInput
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | number | '' | — | The empty string is empty, which is distinct from 0 and must stay that way. |
| onValueChange* | (v: number | '') => void | — | Fires on blur and on each stepper press — not on every keystroke. |
| min | number | — | Clamped on blur. Also becomes aria-valuemin. |
| max | number | — | Clamped on blur. Also becomes aria-valuemax. |
| step | number | 1 | Should match how people think about the value. If a common target takes more than five presses, it is wrong. |
| suffix | string | — | Units shown inside the field. Not part of the value. |
| size | 'sm' | 'md' | 'lg' | 'md' | Steppers keep a fixed target width across sizes. |
CurrencyInput
| Prop | Type | Default | Description |
|---|---|---|---|
| currency | string | 'USD' | ISO code. Drives grouping and decimal places via Intl.NumberFormat. |
| symbol | string | '#x27; | Rendered as a prefix. Never part of the value. |
Professional tips
- Format on blur, edit raw on focus. "$2,400.00" is right for reading and hostile to edit; showing "2400" while focused removes the fight with the cursor.
- Offer presets alongside the field for wide ranges — 30s / 1m / 5m beside a timeout is worth more than any step size.
- Select the whole value on focus for fields users usually replace rather than adjust. Not for ones they nudge, where it destroys the current value on a stray keypress.
- Accept pasted values with symbols and separators — "$1,200" should become 1200 rather than being rejected. People paste from spreadsheets constantly.
- Never use 0 as a placeholder. It is indistinguishable from a real value, and users submit it without realising they never chose it.
Performance
- Hold the draft as a string in local state and lift the parsed number on blur. Parsing on every keystroke in a large form re-renders everything for a value nobody has finished typing.
- Debounce any request the value triggers by about 400ms, or holding the stepper fires one request per repeat.
- Add press-and-hold acceleration on the steppers for wide ranges, capped so it never overshoots past the bound.
Common mistakes
- Scroll-wheel stepping, silently changing a focused field while the user scrolls the page.
- Clamping on every keystroke, so an out-of-range number cannot be typed at all.
- Using it for phone or card numbers, where steppers are meaningless and leading zeros vanish.
- Hiding steppers at the bounds, shifting the layout and removing the only sign a limit exists.
- Treating type="number"’s empty string on invalid input as "the user cleared the field".
- No inputmode, so mobile users get a full keyboard for a digits-only field.
- Steppers with no accessible name, announced as "button, button".
Real-world recommendations
- Quantity steppers in a cart are the highest-traffic instance of this control anywhere, and they are the one case where large flanking +/− buttons beat the stacked layout — the whole interaction is thumb-driven.
- Currency fields should always allow more precision than they display, then round on submit. Silently truncating a third decimal place is a support ticket that takes an hour to reproduce.
- For configuration values, showing the default beside the field ("Default: 3") saves more support time than any amount of validation copy.
- If users routinely type rather than step, the steppers are decoration and the range is probably too wide for the control.