Repo

The number field puts a minus button and a plus button on each side of a number input. The three parts touch. The input has square corners. Only the outer edges of the buttons are rounded.

The buttons use the input's step, min and max. With step="any", they move to the next whole number. The arrow keys also work in the input. The buttons are not in the tab order, because the arrow keys do the same thing.

Usage

<div class="zui-field">
<label class="zui-label" for="quantity">Quantity</label>
<div class="zui-number-field">
  <button type="button" class="zui-number-field-button" tabindex="-1" aria-label="Decrease" data-zui-number-field-step="down">
    <i class="ph ph-minus" aria-hidden="true"></i>
  </button>
  <input class="zui-input zui-number-field-input" type="number" id="quantity" value="1" min="0" />
  <button type="button" class="zui-number-field-button" tabindex="-1" aria-label="Increase" data-zui-number-field-step="up">
    <i class="ph ph-plus" aria-hidden="true"></i>
  </button>
</div>
</div>

<script>
// The buttons need this script in plain HTML. The framework components include it.
document.addEventListener('click', (event) => {
  const button = event.target.closest('[data-zui-number-field-step]')
  const input = button?.closest('.zui-number-field')?.querySelector('input')
  if (!input || input.disabled || input.readOnly) return
  const before = input.value
  // stepUp() throws for step="any". Use step 1 instead.
  const anyStep = input.step.toLowerCase() === 'any'
  if (anyStep) input.step = '1'
  button.dataset.zuiNumberFieldStep === 'down' ? input.stepDown() : input.stepUp()
  if (anyStep) input.step = 'any'
  if (input.value === before) return
  input.dispatchEvent(new Event('input', { bubbles: true }))
  input.dispatchEvent(new Event('change', { bubbles: true }))
})
</script>

Step, min and max

The buttons move the value by step. They stop at min and max.

<div class="zui-number-field">
<button type="button" class="zui-number-field-button" tabindex="-1" aria-label="Decrease" data-zui-number-field-step="down">
  <i class="ph ph-minus" aria-hidden="true"></i>
</button>
<input class="zui-input zui-number-field-input" type="number" aria-label="Volume" value="50" min="0" max="100" step="10" />
<button type="button" class="zui-number-field-button" tabindex="-1" aria-label="Increase" data-zui-number-field-step="up">
  <i class="ph ph-plus" aria-hidden="true"></i>
</button>
</div>

Disabled

A disabled or read-only input also disables the buttons.

<div class="zui-number-field">
<button type="button" class="zui-number-field-button" tabindex="-1" aria-label="Decrease" disabled>
  <i class="ph ph-minus" aria-hidden="true"></i>
</button>
<input class="zui-input zui-number-field-input" type="number" aria-label="Seats" value="4" disabled />
<button type="button" class="zui-number-field-button" tabindex="-1" aria-label="Increase" disabled>
  <i class="ph ph-plus" aria-hidden="true"></i>
</button>
</div>

Shape

Use the shape prop to change the outer corners: default (rounded), hard (square) or soft (pill). The input always has square corners.

<div class="zui-number-field zui-number-field-shape-hard">
<button type="button" class="zui-number-field-button" tabindex="-1" aria-label="Decrease" data-zui-number-field-step="down">
  <i class="ph ph-minus" aria-hidden="true"></i>
</button>
<input class="zui-input zui-number-field-input" type="number" aria-label="Hard" value="1" />
<button type="button" class="zui-number-field-button" tabindex="-1" aria-label="Increase" data-zui-number-field-step="up">
  <i class="ph ph-plus" aria-hidden="true"></i>
</button>
</div>
<div class="zui-number-field zui-number-field-shape-soft">
<button type="button" class="zui-number-field-button" tabindex="-1" aria-label="Decrease" data-zui-number-field-step="down">
  <i class="ph ph-minus" aria-hidden="true"></i>
</button>
<input class="zui-input zui-number-field-input" type="number" aria-label="Soft" value="1" />
<button type="button" class="zui-number-field-button" tabindex="-1" aria-label="Increase" data-zui-number-field-step="up">
  <i class="ph ph-plus" aria-hidden="true"></i>
</button>
</div>

Props

class and shape go on the wrapper. decrementLabel and incrementLabel name the buttons. All other props go on the <input>, so you can use any input prop, for example value, min, max, step, name and disabled. For read-only, use readOnly in React and readonly in Astro, Svelte and Vue. Solid and Vue accept both spellings. You cannot change type. It is always number.

Prop Default Description
shape default Outer corners: default, hard or soft
decrementLabel "Decrease" Accessible name of the minus button
incrementLabel "Increase" Accessible name of the plus button

CSS custom properties

Set these on :root, on a wrapper, or on the element. See Component custom properties. The input also uses the Input properties.

Property Default Description
--zui-number-field-radius var(--zui-input-radius, var(--radius-lg)) Radius of the outer corners
--zui-number-field-input-width 6rem Width of the input
--zui-number-field-button-bg var(--color-surface) Background colour of the buttons

Theme

Copy this CSS to your project: