Stepper
A number between the two buttons that step it.
On this page
A stepper is a small number a reader nudges more often than types: seats, a quantity, a font size. It is an input group around a native <input type="number">, with a button at each end of the well. Add stepper to the group and stepper-field to the field.
Stepper
<div class="sheet flex items-center gap-lg p-md pl-lg"> <label class="text-sm fg-text-strong" for="seats">Seats</label> <div class="input-group stepper"> <button type="button" class="btn btn-square" value="-1" tabindex="-1" aria-label="Fewer seats"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M5 12h14" /> </svg> </button> <input class="input-group-field stepper-field" id="seats" type="number" min="1" max="12" value="4"> <button type="button" class="btn btn-square" value="1" tabindex="-1" aria-label="More seats"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M12 5v14M5 12h14" /> </svg> </button> </div></div><script type="module"> import { attach } from '@master/ui/behaviors' attach()</script>#What the field already does
The field is a number input, so the platform supplies most of a stepper before any script runs:
- ↑ and ↓ step the value by
step, and stop atminandmax. - The field is announced as a spin button with its value and its limits.
- A phone shows its number keys.
:user-invalidreports a typed value outside the range, and the well takes the danger contour.
The stylesheet removes the browser's own spin buttons, because the two buttons replace them.
#The behavior
The stepper behavior makes the two buttons step the number between them. It takes the root, the element with the stepper class, has no options, and returns the function that detaches it.
import { stepper } from '@master/ui/behaviors/stepper.js'const root = document.querySelector('.stepper')stepper(root)root.addEventListener('change', (event) => console.log(event.target.value))- Stepping. A button calls
stepDown()orstepUp()on the field, the same methods the arrow keys use, somin,maxandstepare written once, on the field. - Limits. A button is
disabledwhile the value stands at the limit it steps toward; a disabled step lies flat in the well. An empty field has reached neither limit. Detaching gives the buttons back thedisabledthey had. - Holding. A button held down steps again and again after a short delay, until the pointer lifts, leaves or is cancelled, or the limit is reached.
- Direction. Each button says which way it steps in its
value, negative for down. Without one, the button before the field steps down and the button after it steps up. - Events. The field dispatches
input, thenchange, when a button changed the number, as it does when typed in. The value is the field'svalue.
The buttons carry tabindex="-1": the field is the one stop in the tab order, and the keys already do what the buttons do. Without the behavior the field still does what the platform supplies, and the buttons do nothing.
#Sizes
A stepper takes the group's sizes: input-group-sm is 32px and input-group-lg is 44px, matching a button of the same size. The buttons follow the well: each is 8px shorter, with the concentric corner.
Sizes
<div class="input-group input-group-sm stepper"> <button type="button" class="btn btn-square" value="-1" tabindex="-1" aria-label="Decrease"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M5 12h14" /> </svg> </button> <input class="input-group-field stepper-field" type="number" value="2" aria-label="Small"> <button type="button" class="btn btn-square" value="1" tabindex="-1" aria-label="Increase"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M12 5v14M5 12h14" /> </svg> </button></div><div class="input-group stepper"> <button type="button" class="btn btn-square" value="-1" tabindex="-1" aria-label="Decrease"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M5 12h14" /> </svg> </button> <input class="input-group-field stepper-field" type="number" value="16" aria-label="Medium"> <button type="button" class="btn btn-square" value="1" tabindex="-1" aria-label="Increase"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M12 5v14M5 12h14" /> </svg> </button></div><div class="input-group input-group-lg stepper"> <button type="button" class="btn btn-square" value="-1" tabindex="-1" aria-label="Decrease"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M5 12h14" /> </svg> </button> <input class="input-group-field stepper-field" type="number" value="128" aria-label="Large"> <button type="button" class="btn btn-square" value="1" tabindex="-1" aria-label="Increase"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M12 5v14M5 12h14" /> </svg> </button></div><script type="module"> import { attach } from '@master/ui/behaviors' attach()</script>#Width
The well is as wide as its figure. Where the browser has field-sizing: content, the field follows the digits typed, from three digits to nine; elsewhere it keeps the width of three. Set --stepper-min and --stepper-max for a figure of another length, or give the stepper a width and the field takes what the buttons leave.
A unit is an input-group-addon after the field, and a larger step moves a larger figure.
A unit and a step
<div class="input-group stepper" style="--stepper-min: 5ch"> <button type="button" class="btn btn-square" value="-1" tabindex="-1" aria-label="Lower the budget"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M5 12h14" /> </svg> </button> <input class="input-group-field stepper-field" type="number" min="0" step="250" value="1250" aria-label="Monthly budget"> <span class="input-group-addon">USD</span> <button type="button" class="btn btn-square" value="1" tabindex="-1" aria-label="Raise the budget"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M12 5v14M5 12h14" /> </svg> </button></div><div class="input-group stepper w(280)"> <button type="button" class="btn btn-square" value="-1" tabindex="-1" aria-label="Fewer replicas"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M5 12h14" /> </svg> </button> <input class="input-group-field stepper-field" type="number" min="1" max="64" value="8" aria-label="Replicas"> <button type="button" class="btn btn-square" value="1" tabindex="-1" aria-label="More replicas"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M12 5v14M5 12h14" /> </svg> </button></div><script type="module"> import { attach } from '@master/ui/behaviors' attach()</script>#Disabled
Disable the field and both buttons. The well keeps its place and its figure takes the disabled ink. The behavior does not step a field that is disabled or read-only. This preview is markup only, with no script attached.
Disabled
<div class="input-group stepper"> <button type="button" class="btn btn-square" tabindex="-1" aria-label="Decrease" disabled> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M5 12h14" /> </svg> </button> <input class="input-group-field stepper-field" type="number" value="3" aria-label="Seats included in your plan" disabled> <button type="button" class="btn btn-square" tabindex="-1" aria-label="Increase" disabled> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d="M12 5v14M5 12h14" /> </svg> </button></div>#When to use something else
- A number that is typed once and rarely adjusted, such as a port or a price, is a plain input with
type="number". - A position in a wide range, where the exact figure matters less than the feel, is a slider.
- A choice among a few named amounts is a segmented control.
#Keyboard
The keys are the number input's own; the behavior adds none.
| Keys | Action |
|---|---|
| ArrowUp, ArrowDown | Step the value by step and stop at min and max, as a number input does |
#Accessibility
- Label the field with a
<label>, a field oraria-label. Each button needs its own name that says what it changes: "More seats", not "Plus". - Keep
min,maxandstepon the field. They are what the keys, the buttons and assistive technology all read. - The buttons stay out of the tab order. A reader on a keyboard steps with the arrow keys in the field; a reader with a pointer has targets of 30px, and a finger 36px inside a 44px well.
#React, Svelte and Vue
The stepper component attaches the behavior and passes the number in and out. Copy it into the project with npx master-ui add stepper; React, Svelte and Vue says how the components are built and used.
#Classes
| Class | Description |
|---|---|
stepper | On an input-group: a well as wide as its figure, with a button at each end. A button disabled at its limit lies flat in the well |
stepper-field | On the group's input-group-field, a native number input: centered tabular figures, no spin buttons |
#Custom properties
| Property | Default | Description |
|---|---|---|
--stepper-min | 3ch | The narrowest the figure's box gets |
--stepper-max | 9ch | The widest it grows with field-sizing |
#Related
- InputText fields cut into the surface, with labels, hints, errors and groups.
- SliderA native range input: a thin well with a pearl thumb.
- ButtonA raised control that lifts on hover and sinks when pressed.