Navigation

v0.0.4
Forms

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>
Press a button or hold it down, type a number, or use the arrow keys in the field. At 1 and at 12 a button lies flat.

#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 at min and max.
  • The field is announced as a spin button with its value and its limits.
  • A phone shows its number keys.
  • :user-invalid reports 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.

app.js
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() or stepUp() on the field, the same methods the arrow keys use, so min, max and step are written once, on the field.
  • Limits. A button is disabled while 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 the disabled they 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, then change, when a button changed the number, as it does when typed in. The value is the field's value.

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.

KeysAction
ArrowUp, ArrowDownStep the value by step and stop at min and max, as a number input does

#Accessibility

  • Label the field with a <label>, a field or aria-label. Each button needs its own name that says what it changes: "More seats", not "Plus".
  • Keep min, max and step on 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.

ui/stepper.tsx
'use client'import { useState, type ComponentProps } from 'react'import { stepper } from '@master/ui/behaviors/stepper.js'import { useBehavior } from './use-behavior'export interface StepperProps extends Omit<ComponentProps<'input'>, 'value' | 'defaultValue' | 'type'> {  /** The number; null while the field is empty. */  value?: number | null  defaultValue?: number | null  onValueChange?: (value: number | null) => void  /** The names of the two buttons, which show only an icon. */  decrementLabel: string  incrementLabel: string}/** * A number between two buttons. Name the field with `aria-label`, or give it an `id` a label points at; * `min`, `max`, `step` and every other prop of an input go to the field. */export function Stepper({ value, defaultValue, onValueChange, decrementLabel, incrementLabel, className, ...field }: StepperProps) {  const ref = useBehavior(stepper, undefined, { value, defaultValue, onValueChange })  const [first] = useState(value ?? defaultValue)  return (    <div className={className ? `input-group stepper ${className}` : 'input-group stepper'} ref={ref}>      <button type="button" className="btn btn-square" value="-1" tabIndex={-1} aria-label={decrementLabel}>        <svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12h14" /></svg>      </button>      <input className="input-group-field stepper-field" type="number" defaultValue={first ?? undefined} {...field} />      <button type="button" className="btn btn-square" value="1" tabIndex={-1} aria-label={incrementLabel}>        <svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>      </button>    </div>  )}
ui/Stepper.svelte
<script lang="ts" module>  import type { HTMLInputAttributes } from 'svelte/elements'  export interface StepperProps extends Omit<HTMLInputAttributes, 'value' | 'type' | 'children'> {    /** The number; null while the field is empty. Bind it, or leave it out. */    value?: number | null    onValueChange?: (value: number | null) => void    /** The names of the two buttons, which show only an icon. */    decrementLabel: string    incrementLabel: string  }</script><script lang="ts">  import { untrack } from 'svelte'  import { stepper } from '@master/ui/behaviors/stepper.js'  import { behavior } from './behavior.svelte.js'  let { value = $bindable(), onValueChange, decrementLabel, incrementLabel, class: className, ...field }: StepperProps = $props()  const first = untrack(() => value)  const attach = behavior(stepper, undefined, {    value: () => value,    change: (next) => { value = next; onValueChange?.(next) },  })</script><!--  @component  A number between two buttons. Name the field with `aria-label`, or give it an `id` a label points at;  `min`, `max`, `step` and every other prop of an input go to the field.--><div class={['input-group', 'stepper', className]} {@attach attach}>  <button type="button" class="btn btn-square" value="-1" tabindex={-1} aria-label={decrementLabel}>    <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={first ?? undefined} {...field} />  <button type="button" class="btn btn-square" value="1" tabindex={-1} aria-label={incrementLabel}>    <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>
ui/Stepper.vue
<script setup lang="ts">import { useAttrs, useTemplateRef } from 'vue'import { stepper } from '@master/ui/behaviors/stepper.js'import { useBehavior } from './use-behavior'defineOptions({ inheritAttrs: false })defineProps<{  /** The names of the two buttons, which show only an icon. */  decrementLabel: string  incrementLabel: string}>()/** The number; null while the field is empty: `v-model`. */const model = defineModel<number | null>()/* * A number between two buttons. Name the field with `aria-label`, or give it an `id` a label points at; * `min`, `max`, `step` and every other attribute but the class go to the field. */const attrs = useAttrs()const field = () => {  const { class: _, ...rest } = attrs  return rest}// The first number is the field's default value, an attribute: what is typed after is not Vue's to write.const first = model.valueconst root = useTemplateRef<HTMLDivElement>('root')useBehavior(root, stepper, undefined, model)</script><template>  <div class="input-group stepper" :class="$attrs.class" ref="root">    <button type="button" class="btn btn-square" value="-1" tabindex="-1" :aria-label="decrementLabel">      <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.attr="first ?? undefined" v-bind="field()">    <button type="button" class="btn btn-square" value="1" tabindex="-1" :aria-label="incrementLabel">      <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></template>

#Classes

ClassDescription
stepperOn 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-fieldOn the group's input-group-field, a native number input: centered tabular figures, no spin buttons

#Custom properties

PropertyDefaultDescription
--stepper-min3chThe narrowest the figure's box gets
--stepper-max9chThe widest it grows with field-sizing

#Source

@master/ui/components/stepper.css
/* * Stepper: a number between the two buttons that step it. * It is an input group around a native number input: the well holds a small raised button at each end, 4px in, * and the figure stands centered between them in tabular numerals. The field keeps what the platform gives a * number input: the arrow keys, min, max, step and the spinbutton role. The buttons call stepDown() and * stepUp() from a few lines of script; without it the field still types and steps from the keys. * The browser's own spin buttons are removed, since the two buttons replace them. * The well is as wide as its figure. Where field-sizing exists it follows the digits typed, between * --stepper-min and --stepper-max; elsewhere it keeps --stepper-min. */@layer components {  .stepper {    --stepper-min: 3ch;    --stepper-max: 9ch;    display: inline-flex;    max-width: 100%;    vertical-align: middle;    /* The buttons stand 4px from the figure's box, as they stand 4px from the well. */    gap: 4px;    cursor: default;    /* A step that has reached its limit lies flat in the well: no face, no edge, only its quiet glyph. */    .btn:disabled {      background: transparent;      box-shadow: none;    }  }  .stepper-field {    flex: 1 1 auto;    width: calc(var(--stepper-min) + 1rem);    padding-inline: .5rem;    font-weight: var(--font-weight-control);    font-variant-numeric: tabular-nums;    text-align: center;    appearance: textfield;    cursor: text;    &::-webkit-inner-spin-button, &::-webkit-outer-spin-button {      display: none;    }    @supports (field-sizing: content) {      width: auto;      min-width: calc(var(--stepper-min) + 1rem);      max-width: calc(var(--stepper-max) + 1rem);      field-sizing: content;    }  }}