Number Field
A number input with decrease and increase buttons on each side.
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>import { Field, Label, NumberField } from '@mrmartineau/zui/react'
<Field>
<Label htmlFor="quantity">Quantity</Label>
<NumberField id="quantity" defaultValue={1} min={0} />
</Field>---
import { Field, Label, NumberField } from '@mrmartineau/zui/astro'
---
<Field>
<Label for="quantity">Quantity</Label>
<NumberField id="quantity" value={1} min={0} />
</Field>import { Field, Label, NumberField } from '@mrmartineau/zui/solid'
<Field>
<Label for="quantity">Quantity</Label>
<NumberField id="quantity" value={1} min={0} />
</Field><script lang="ts">
import { Field, Label, NumberField } from '@mrmartineau/zui/svelte'
let quantity = $state(1)
</script>
<Field>
<Label for="quantity">Quantity</Label>
<NumberField id="quantity" min={0} bind:value={quantity} />
</Field><template>
<Field>
<Label for="quantity">Quantity</Label>
<NumberField id="quantity" value="1" min="0" />
</Field>
</template>
<script setup>
import { Field, Label, NumberField } from '@mrmartineau/zui/vue'
</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><NumberField aria-label="Volume" defaultValue={50} min={0} max={100} step={10} /><NumberField aria-label="Volume" value={50} min={0} max={100} step={10} /><NumberField aria-label="Volume" value={50} min={0} max={100} step={10} /><NumberField aria-label="Volume" value={50} min={0} max={100} step={10} /><NumberField aria-label="Volume" value="50" min="0" max="100" step="10" />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><NumberField aria-label="Seats" defaultValue={4} disabled /><NumberField aria-label="Seats" value={4} disabled /><NumberField aria-label="Seats" value={4} disabled /><NumberField aria-label="Seats" value={4} disabled /><NumberField aria-label="Seats" value="4" disabled />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><NumberField aria-label="Hard" shape="hard" defaultValue={1} />
<NumberField aria-label="Soft" shape="soft" defaultValue={1} /><NumberField aria-label="Hard" shape="hard" value={1} />
<NumberField aria-label="Soft" shape="soft" value={1} /><NumberField aria-label="Hard" shape="hard" value={1} />
<NumberField aria-label="Soft" shape="soft" value={1} /><NumberField aria-label="Hard" shape="hard" value={1} />
<NumberField aria-label="Soft" shape="soft" value={1} /><NumberField aria-label="Hard" shape="hard" value="1" />
<NumberField aria-label="Soft" shape="soft" value="1" />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 |