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.

Press and hold either button to repeat
<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, min and max off 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" produces 3.25, not 3.2500000000000004 — the value is written with toFixed() at the step's own precision.
  • Disables the button at the limit. At min the minus button goes disabled, 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 input and change. 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.
Left: a fractional step. Right: already at its minimum, so minus is disabled
<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.

The unit is in the input's own label as well, because the cell is decoration
sec
<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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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, its min and max in 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-unit is 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 is disabled and 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.

Minus on the right, unit on the left — the control mirrors as a whole
مقعد
<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 .phone or a text input with inputmode.
  • 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.