macbetter

Buttons and controls

Stepper

Change a number by small, fixed steps, with or without a field to type it.

import { Field, FieldLabel } from "macbetter/field"
import { NumberField } from "macbetter/stepper"

export default function Example() {
  return (
    <Field layout="horizontal">
      <FieldLabel>Copies</FieldLabel>
      <NumberField defaultValue={1} min={1} max={99} />
    </Field>
  )
}

When to use it

Use a stepper for a number that people usually change by a little, like copies to print, guests at a table or a font size. It suits small ranges; for a wide range or an imprecise value, use a Slider.

  • NumberField pairs a text field with the stepper, so people can type a number or step it. Use it whenever typing a value could be faster than stepping to it.
  • Stepper is the buttons alone, for a value shown elsewhere, like "Guests: 2" in a row's title. Always show the value next to it.

Usage

import { NumberField, Stepper } from "macbetter/stepper"

Name the field with a Field label, aria-label or aria-labelledby. The buttons are named "Decrease" and "Increase"; change them with decrementLabel and incrementLabel.

Stepper alone

Its number field is visually hidden but still takes focus, so keyboard and screen reader users can step it with the arrow keys. Point aria-labelledby at the visible value.

Guests: 2
"use client"

import { Stepper } from "macbetter/stepper"
import { useState } from "react"

export default function Example() {
  const [guests, setGuests] = useState<number | null>(2)

  return (
    <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 24, width: "100%", maxWidth: 320 }}>
      <span id="guests-label">
        Guests: <span style={{ fontVariantNumeric: "tabular-nums" }}>{guests}</span>
      </span>
      <Stepper aria-labelledby="guests-label" value={guests} onValueChange={setGuests} min={1} max={8} />
    </div>
  )
}

Step and format

min, max and step set the bounds and the step. format takes Intl.NumberFormat options; for percents, the value is a fraction, so 75% is 0.75.

import { Field, FieldGroup, FieldLabel } from "macbetter/field"
import { NumberField } from "macbetter/stepper"

export default function Example() {
  return (
    <FieldGroup layout="horizontal">
      <Field>
        <FieldLabel>Font size</FieldLabel>
        <NumberField defaultValue={13} min={8} max={72} step={1} />
      </Field>
      <Field>
        <FieldLabel>Line spacing</FieldLabel>
        <NumberField defaultValue={1.2} min={0.8} max={3} step={0.1} format={{ minimumFractionDigits: 1 }} />
      </Field>
      <Field>
        <FieldLabel>Opacity</FieldLabel>
        <NumberField defaultValue={0.75} min={0} max={1} step={0.05} format={{ style: "percent" }} />
      </Field>
    </FieldGroup>
  )
}

Sizes

import { NumberField } from "macbetter/stepper"

export default function Example() {
  return (
    <>
      <NumberField size="sm" aria-label="Columns" defaultValue={3} min={1} max={12} />
      <NumberField size="md" aria-label="Rows" defaultValue={8} min={1} max={100} />
    </>
  )
}

Disabled

import { NumberField, Stepper } from "macbetter/stepper"

export default function Example() {
  return (
    <>
      <NumberField disabled aria-label="Retries" defaultValue={3} />
      <Stepper disabled aria-label="Retries" defaultValue={3} />
    </>
  )
}

Platform behavior

  • macOS. A text field with an NSStepper beside it: a narrow, raised control split into up and down arrows. Numbers align to the trailing edge of the field. The field shows the accent focus ring whenever it has focus.
  • iPadOS and iOS. The UIStepper capsule, with − and + separated by a hairline, beside the value. The field drops its border and reads as the value itself.
  • Every platform. Holding a button repeats the step. A button at its bound dims and stops responding.

Accessibility

  • The field follows the spinbutton keyboard: ArrowUp and ArrowDown step, Shift steps by ten, Page Up and Page Down by the large step, Home and End go to the bounds, and Enter or leaving the field commits typed text.
  • The step buttons stay out of the tab order, because the focused field gives the same steps from the keyboard. Each points at the field with aria-controls.
  • The standalone stepper draws the focus ring around its buttons when its hidden field has focus.

API

NumberField and Stepper

Both take the same props.

PropTypeDefaultDescription
valuenumber | null—The value, or null when empty. Pass it to control the field.
defaultValuenumber | null—The value at first.
onValueChange(value, details) => void—Runs as typing becomes valid and on every step.
onValueCommit(value, details) => void—Runs once an interaction ends. For saving.
minnumber—The lowest value.
maxnumber—The highest value.
stepnumber1How far one step moves.
largeStepnumber—How far Page Up, Page Down and Shift move.
formatIntl.NumberFormatOptions—How the value shows and how typing is read.
size"sm" | "md""md"Height of the field and stepper.
decrementLabelstring"Decrease"Names the decrement button.
incrementLabelstring"Increase"Names the increment button.
aria-labelstring—Names the number field.
placeholderstring—NumberField only: shown while empty.
disabledbooleanfalseCan't be focused or changed.
readOnlybooleanfalseFocusable and submitted, but doesn't change.
namestring—The name the number submits under.

Data attributes

AttributeOnValues
data-sizeNumberField, Steppersm, md
data-stepButtonsdecrement, increment
data-disabledButtonsPresent when the button can't step, including at a bound
data-disabled, data-readonlyRootPresent in those states

CSS variables

VariablemacOSiOSWhat it sets
--mb-stepper-width15px94pxWidth of the stepper
--mb-stepper-height--mb-control-height32pxHeight of the field and stepper
--mb-stepper-height-sm--mb-control-height-sm28pxSmall height
--mb-number-field-width64px5chWidth of the text field