Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Between 1 and 12.

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.

$USD

Billed in USD.

$USD

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.

Clamp on blur

Between 1 and 12.

Adjusted to the maximum of 12.

Clamp per keystroke

Typing “25” gives you “2”, then fights you.

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.

sec
%

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.

Default
At minimum
At maximum
Empty
Error
Disabled
Read only
$USD
Currency
sec
With unit
Decimal

Anatomy

Every part, every measurement, and the reason it is that number.

pods

Between 1 and 12.

A right-aligned tabular value, a unit suffix, and stacked steppers that disable at the bounds rather than disappearing.

  1. 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.

  2. 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.

  3. 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.

  4. Bound behaviourDisabled, never hidden

    A stepper that vanishes at the limit shifts the layout and removes the only signal that a limit exists.

  5. 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.

  6. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
paddingReduced on the stepper side

Radius

TokenValueUsed for
--radius-mdField corners

Typography

TokenValueUsed for
tabular-nums—The value, so digits do not shift width

Motion

TokenValueUsed for
--duration-fastStepper hover and press

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingTypeMin widthTouch targetWhen to use
Small32px0 6px 0 10px13px5.5rem—Table cells and dense filter bars.
Medium36px0 8px 0 12px15px7rem—The default.
Large44px0 10px 0 14px16px8rem—Touch-first layouts and checkout quantities.
Stepper16px——20px44px combined on coarse pointersStacked. Disabled at the bounds, never removed.
Currency———9rem—Extra room for the symbol prefix and two decimal places.

Do

onBlur → clamp(value, min, max)
✗ onChange → clamp(…)
Clamp on blur, not per keystrokeTyping "25" into a max-12 field passes through "2". Clamp as they type and the field fights every character after the first.
Disable the steppers at the boundsIt is the only affordance that says a limit exists. Hiding them shifts the layout at exactly the moment the user has reached the edge.
sec
Put the unit in the field"Timeout: 30" is seconds or milliseconds depending on who reads it. A suffix inside the control removes the guess for everyone.
type="text" inputmode="decimal"
pattern="[0-9]*"
Set inputmode, not just typeinputmode="decimal" gives mobile users a numeric keypad without inheriting type="number"’s scroll-stepping and its empty-string-on-invalid behaviour.

Don't

focus + scroll page → replicas: 3 → 47
Do not let the scroll wheel change the valueA user scrolling the page past a focused field silently changes a number they never touched, and they find out at submit. Blur on wheel, or prevent it outright.
Do not use it for identifiersPhone numbers, card numbers and PINs are digit strings, not quantities. Steppers are meaningless on them, and leading zeros get eaten.
Do not stretch it to the form widthField width is a hint about magnitude. A full-width field for a value between 1 and 12 reads as a place to type a large number.
→ 60 presses to 300
Do not make the steppers the only way to reach a valueStepping from 0 to 300 in fives is sixty presses. The field must always accept a typed value, and a big range needs presets rather than patience.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.5Identify Input PurposeAA2.1.1KeyboardA2.5.8Target Size (Minimum)AA3.3.1Error IdentificationA4.1.2Name, Role, ValueA

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 DownSteps by a larger amount — ten steps by convention. Free to add and invaluable on a wide range.
Home / EndJumps to min or max when both are defined.
TabLeaves the field. The steppers are not separate tab stops — arrows already do their job.
Scroll wheelNothing. 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.
AttributeApplied toNotes
role="spinbutton"The fieldImplicit on input[type=number]; explicit when using type="text" with inputmode.
aria-valuenow / valuemin / valuemaxThe fieldHow the range reaches a screen-reader user. Disabled steppers do not communicate bounds on their own.
aria-valuetextThe fieldFor values that need units or formatting: "30 seconds", "$2,400". A bare "30" is ambiguous read aloud.
aria-labelEach stepper"Increase replicas" / "Decrease replicas". A bare "+" is not a name.
aria-hiddenThe steppersA defensible alternative — arrow keys already provide the function, and hiding them removes two redundant stops from the screen-reader path.
inputmode="decimal"The fieldGives a numeric keypad on mobile without inheriting type="number"’s behaviour.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
minnumber—Clamped on blur. Also becomes aria-valuemin.
maxnumber—Clamped on blur. Also becomes aria-valuemax.
stepnumber1Should match how people think about the value. If a common target takes more than five presses, it is wrong.
suffixstring—Units shown inside the field. Not part of the value.
size'sm' | 'md' | 'lg''md'Steppers keep a fixed target width across sizes.

CurrencyInput

PropTypeDefaultDescription
currencystring'USD'ISO code. Drives grouping and decimal places via Intl.NumberFormat.
symbolstring'
#x27;
Rendered as a prefix. Never part of the value.

Notes

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.