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.
NumberFieldpairs 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.Stepperis 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.
"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.
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | null | — | The value, or null when empty. Pass it to control the field. |
defaultValue | number | 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. |
min | number | — | The lowest value. |
max | number | — | The highest value. |
step | number | 1 | How far one step moves. |
largeStep | number | — | How far Page Up, Page Down and Shift move. |
format | Intl.NumberFormatOptions | — | How the value shows and how typing is read. |
size | "sm" | "md" | "md" | Height of the field and stepper. |
decrementLabel | string | "Decrease" | Names the decrement button. |
incrementLabel | string | "Increase" | Names the increment button. |
aria-label | string | — | Names the number field. |
placeholder | string | — | NumberField only: shown while empty. |
disabled | boolean | false | Can't be focused or changed. |
readOnly | boolean | false | Focusable and submitted, but doesn't change. |
name | string | — | The name the number submits under. |
Data attributes
| Attribute | On | Values |
|---|---|---|
data-size | NumberField, Stepper | sm, md |
data-step | Buttons | decrement, increment |
data-disabled | Buttons | Present when the button can't step, including at a bound |
data-disabled, data-readonly | Root | Present in those states |
CSS variables
| Variable | macOS | iOS | What it sets |
|---|---|---|---|
--mb-stepper-width | 15px | 94px | Width of the stepper |
--mb-stepper-height | --mb-control-height | 32px | Height of the field and stepper |
--mb-stepper-height-sm | --mb-control-height-sm | 28px | Small height |
--mb-number-field-width | 64px | 5ch | Width of the text field |