Number input
The browser's own spinner arrows are about eight pixels tall, sit on top of each other,
look different in every engine and are invisible on touch. .number hides
them and puts two real buttons either side of the field — each a full control height, so
they are actually hittable.
When to use it
For a quantity someone adjusts by small amounts and can see the whole range of: items in a basket, seats on a plan, a retry count. The buttons are worth their space when stepping is the common action.
For a number that is typed rather than nudged — a price, a year, an invoice number — a
plain .input is better. Two buttons nobody presses
are two buttons in the way.
<div class="field" style="max-inline-size:14rem">
<label class="label" for="dx-num-a">Seats</label>
<div class="number">
<button type="button" aria-label="One fewer seat">−</button>
<input id="dx-num-a" type="number" value="3" min="1" max="20">
<button type="button" aria-label="One more seat">+</button>
</div>
</div>
<div class="field" style="max-inline-size:14rem">
<label class="label" for="dx-num-a">Seats</label>
<div class="number">
<button type="button" aria-label="One fewer seat">−</button>
<input id="dx-num-a" type="number" value="3" min="1" max="20">
<button type="button" aria-label="One more seat">+</button>
</div>
</div>
The first child decrements and the last increments —
deck-extras.js works that out from position. Put them the other way round
in the DOM and the minus button adds. If your markup cannot follow that order, set
data-step="-1" and data-step="1" explicitly and position is
ignored.
What the script does
-
Reads
step,minandmaxoff the input. They are ordinary attributes, so the same values constrain typing, constrain the buttons and are enforced by the browser on submit. -
Keeps the decimals of the step. A
step="0.25"produces3.25, not3.2500000000000004— the value is written withtoFixed()at the step's own precision. -
Disables the button at the limit. At
minthe minus button goesdisabled, and it is recomputed whenever the value changes, including when it is typed. - Repeats on hold. 420ms to start, then a step every 70ms. Long enough that a normal press is a single step.
-
Fires
inputandchange. Both bubble, so anything already listening to the field — a total, a validity check, a framework binding — sees a button press exactly as if the value had been typed.
<div class="cluster">
<div class="number number-sm">
<button type="button" aria-label="Decrease">−</button>
<input type="number" value="1.00" step="0.25" min="0" max="5" aria-label="Rate">
<button type="button" aria-label="Increase">+</button>
</div>
<div class="number">
<button type="button" aria-label="Decrease">−</button>
<input type="number" value="0" min="0" max="3" aria-label="Extras">
<button type="button" aria-label="Increase">+</button>
</div>
</div>
<div class="cluster">
<div class="number number-sm">
<button type="button" aria-label="Decrease">−</button>
<input type="number" value="1.00" step="0.25" min="0" max="5" aria-label="Rate">
<button type="button" aria-label="Increase">+</button>
</div>
<div class="number">
<button type="button" aria-label="Decrease">−</button>
<input type="number" value="0" min="0" max="3" aria-label="Extras">
<button type="button" aria-label="Increase">+</button>
</div>
</div>
Units and size
.number-unit is a tinted cell welded to the end of the row for the unit the
figure is in. .number-sm shrinks the control and narrows the box.
<div class="cluster">
<div class="number">
<button type="button" aria-label="Decrease">−</button>
<input type="number" value="30" step="5" min="0" aria-label="Timeout in seconds">
<button type="button" aria-label="Increase">+</button>
<span class="number-unit">sec</span>
</div>
<div class="number number-sm">
<button type="button" aria-label="Decrease">−</button>
<input type="number" value="2" min="1" aria-label="Copies">
<button type="button" aria-label="Increase">+</button>
</div>
</div>
<div class="cluster">
<div class="number">
<button type="button" aria-label="Decrease">−</button>
<input type="number" value="30" step="5" min="0" aria-label="Timeout in seconds">
<button type="button" aria-label="Increase">+</button>
<span class="number-unit">sec</span>
</div>
<div class="number number-sm">
<button type="button" aria-label="Decrease">−</button>
<input type="number" value="2" min="1" aria-label="Copies">
<button type="button" aria-label="Increase">+</button>
</div>
</div>
Width comes from --number-width, which defaults to 11rem and
is what .number-sm changes. It is a component knob rather than a global
token, so set it inline or in your own layer for a one-off.
Classes
Generated from src/23-inputs.css by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.number |
deck.components |
Native spinners are tiny and inconsistent across browsers. These are real buttons that clear the touch target. | 23-inputs.css:86 |
.number-sm |
deck.components |
Sets --number-width. | 23-inputs.css:141 |
.number-unit |
deck.components |
Sets display, place-items, padding-inline, background, and 4 more. | 23-inputs.css:131 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--control-h |
42px |
|
--control-h-sm |
34px |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--line-strong |
light-dark(var(--ink-300), oklch(36% .018 var(--hue-neutral))) |
|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--surface-2 |
light-dark(var(--ink-25), var(--ink-900)) |
|
--surface-hover |
light-dark(var(--ink-50), var(--ink-900)) |
|
--brand-soft |
light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand))) |
|
--focus |
light-dark(var(--brand-500), var(--brand-300)) |
|
--ring |
0 0 0 3px light-dark(oklch(from var(--focus) l c h / .32), oklch(from var(--focus) l c h / .42)) |
|
--r-sm |
7px |
|
--dur-1 |
110ms |
--number-width is a component-level custom property with an
11rem fallback, not one of Deck's tokens — it is not in the table above
because it does not exist until you or .number-sm set it.
Accessibility
-
The input is a real
<input type="number">. It keeps its role, its value, itsminandmaxin the accessibility tree, and its native keyboard behaviour. Up and down arrows step the value without any script at all — the buttons are for pointers, not a replacement for the keyboard. -
Both buttons need
type="button". A<button>with no type submits the form it is in. Every example on this page sets it, and this is the single most common way to break this component. -
Both buttons need a label. "−" and "+" are announced as "minus" and
"plus" with no object.
aria-label="One more seat"is the difference between a control and a mystery. -
The unit cell is decoration.
.number-unitis a<span>beside the field, not part of its accessible name. Put the unit in the label — "Timeout in seconds" — or the reader hears a bare number. -
A disabled button disappears from the tab order. When the value hits
min, the minus button isdisabledand a keyboard user tabbing through loses it with no announcement. The value itself is still reachable and the arrow keys still work, so nothing is lost — but do not build a flow that depends on that button being focusable. -
Nothing announces the new value. Pressing plus changes the field
silently unless the reader is focused in it. Where the number drives something visible
— a total, a price — that total needs
aria-live; the stepper cannot do it. -
16px on coarse pointers.
@media (pointer: coarse)forces the input to 16px, because iOS Safari zooms the whole page when it focuses a field smaller than that. It is a deliberate override of your type scale.
With JavaScript off
This is the one component on this page with a real gap, and it is worth stating plainly.
The stylesheet removes the native spinners with
appearance: textfield; the replacement buttons are wired by
deck-extras.js. With the script blocked you get neither — the buttons are
inert and the native arrows are gone.
What survives is the field itself: it is still a real number input, so typing works, validation works, the form posts, and the up and down arrow keys still step the value because that is the browser's behaviour rather than Deck's. So the control degrades to a plain number field with two dead buttons beside it. Usable, and untidy.
If a page has to work without script, use a plain
.input with type="number" and let the
browser draw its own spinners. Recorded in FINDINGS.md.
Right to left
The row is a flex container, so it follows the document direction and the whole control
mirrors: the button that is first in the DOM — the one that decrements — is drawn on the
right. The separators between the cells are
border-inline-start and border-inline-end, so they follow too.
Nothing in src/19-logical.css is needed.
<div dir="rtl" class="number">
<button type="button" aria-label="إنقاص">−</button>
<input type="number" value="٣" min="1" max="20" aria-label="المقاعد">
<button type="button" aria-label="زيادة">+</button>
<span class="number-unit">مقعد</span>
</div>
<div dir="rtl" class="number">
<button type="button" aria-label="إنقاص">−</button>
<input type="number" value="٣" min="1" max="20" aria-label="المقاعد">
<button type="button" aria-label="زيادة">+</button>
<span class="number-unit">مقعد</span>
</div>
Digits are left-to-right in the bidirectional algorithm whatever the surrounding
direction, so the number itself reads normally without the direction: ltr
that the phone field needs.
Reduced motion
Only the buttons animate — background-color and color over
110ms on hover — and the global reset in
src/02-reset.css collapses that. The press-and-hold repeat is not motion and
is not affected: it is the control doing what it was asked to.
Printing
.number is not in src/99-print.css. The row prints with both
buttons — a minus and a plus beside a figure, which on paper is meaningless furniture
and, worse, reads as part of the value.
@media print {
.number > button, .number-unit { display: none; }
.number { border: 0; box-shadow: none; }
}
Keep the unit if the figure is meaningless without it — in that case hide only the buttons.
Overriding it
/* One control, no layer needed */
<div class="number" style="--number-width:7rem"> … </div>
@layer app.components {
/* Left-align the figure rather than centring it */
.number > input { text-align: start; padding-inline: var(--space-3); }
/* A wider default across the app */
.number { --number-width: 13rem; }
}
When not to use it
-
Not for a number people type. A year, a price, an account number —
nobody steps to 1987. Use an
.input. -
Not for a wide range. Stepping from 0 to 900 is not a control, it is
a punishment. Use a
.range, or let them type. -
Not for a value with meaningful text. A phone number, a card number
and a postcode are digits but not quantities;
type="number"strips leading zeros and offers a spinner for something that cannot be incremented. Use.phoneor a text input withinputmode. - Not where script may not run. See With JavaScript off.
- Not for a choice from a short list. One to four guests is a segmented control — one press instead of three.