Stepper
Tap-only numeric quantity control — five variants (soft, outline, ghost, split, joined), single-tab-stop spinbutton keyboard model, hold-to-repeat, trash-at-min removal, value formatting, on-color tone, and native form participation.
pnpm add @tessinaui/uiWhen to use
- Small adjustments to a number. A Stepper changes a value a few taps at a time: cart quantities, guest counts, portions, goals.
- A bounded value near a sensible default. Always give it a
defaultValue(cart-style contexts usually want1) so it is never valueless, and setminandmaxso the exhausted button dims in place. - A quantity in a cart row. With
removable, the decrement becomes a remove action atmin, so one control covers "fewer" and "none". - A value that carries a unit.
formatValuerenders it and feedsaria-valuetext, so the announcement matches the pixels. - A control beside a primary action. Sizes sit on the same height ladder as Field and Select, so a Stepper lines up in a form row or a sheet footer.
When not to use
- When people should type a number, make large jumps or enter a precise value across a wide range. Use NumberField.
- For workout or measurement logging. Steppers are too slow for three-digit entry; use a keypad-friendly NumberField.
- For an approximate value on a continuum. Use Slider.
- For a long fixed list of options (apparel sizes, case packs). Use Select.
- For durations and times. Use TimePicker; a wheel suits them better than a stepper.
- To show where someone is in a multi-step flow. A Stepper changes a number; it is not a wizard step indicator. Use Progress for progress through a flow.
Installation
pnpm add @tessinaui/uiUsage
A Stepper changes a number a few taps at a time: cart quantities, guest counts,
portions, goals. It is not a wizard step indicator (that component is the
future Steps; some systems use the name "Stepper" for
wizards — Tessina does not), and it is not a typing surface: when people should
enter a number, reach for NumberField.
import { Stepper } from "@tessinaui/ui";{/* Basic uncontrolled */}
<Stepper defaultValue={1} min={0} max={10} />
{/* Controlled, with change reasons */}
<Stepper
value={quantity}
onValueChange={(next, { reason }) => setQuantity(next)}
onValueCommitted={(settled) => saveQuantity(settled)}
min={1}
max={99}
/>
{/* Cart row: trash at minimum */}
<Stepper min={1} removable onRemove={removeLineItem} />
{/* Formatted value (visible text AND aria-valuetext) */}
<Stepper formatValue={(v) => `${v} kg`} step={5} largeStep={25} />Examples
Default
A bounded quantity. The exhausted button dims in place — it never
disappears, never collapses layout, and stays discoverable to assistive tech
via aria-disabled.
Variants
soft shares one filled container, outline one bordered container, ghost
is bare glyphs, split is the travel-app pattern of separate outlined
circles, and joined is a fused two-segment control (its value always lives
outside — see the external-value example).
emphasizeIncrement fills only the + button, encoding "adding is the primary
action".
Sizes
Five sizes on the input-family ladder — 32 / 36 / 40 / 48 / 56px, the same
heights Field and Select render, so a Stepper and a Select with the same
size line up in one form row. Every button floors to 44×44px on a touch
screen, tablets included.
The button ladder is a different scale (32 / 40 / 48 / 56 / 96), so a footer
row pairs the two one token apart — Stepper size="lg" with
Button size="md" are both 48px, xs with xs are both 32px.
Intents
The full palette on both the soft container and split buttons.
intent="error" also stamps aria-invalid.
States
disabled (out of the tab order), readOnly (focusable-but-frozen — the
value keeps full contrast), pending (an async mutation is in flight:
aria-busy, stepping refused), the at-minimum boundary, and the skeleton.
With label and validation
label names the control; supportingText is describedby-wired so assistive
tech hears it. errorMessage replaces the helper while present, implies
intent="error", marks the spinbutton invalid and is announced politely.
Cart row (removable)
At min with removable, the decrement becomes a trash action with a
Remove … accessible name and fires onRemove instead of stepping — the
dominant post-add cart pattern.
Guest counter panel
Label + sublabel rows with split steppers end-aligned, inside a Popover —
the composition the Select docs route guest/traveler pickers to. Limits are
communicated as helper text near the panel, not on the control.
Add → stepper
An Add button and the stepper share one geometry, so the control morphs in
place after the first add and collapses back at zero via removable.
Sheet footer with CTA
Height-matched beside the primary action, quantity driving the CTA's live price.
Formatted values
formatValue renders units, zero-padding, or locale separators — and feeds
aria-valuetext, so the announcement matches the pixels. largeStep covers
PageUp/PageDown and Shift+Arrow.
External value
joined never shows its value inline (pair it with an adjacent readout);
showValue={false} builds hero displays with the number rendered at display
scale. In both, aria-valuenow/aria-valuetext keep the control fully
announced.
On color
On a colored Surface the tone is inherited; every ink derives from the
surface's currentColor contract. intent="primary" falls back to the
surface ink — a self-colored chip cannot adapt to an arbitrary plate.
In a form
name renders a hidden input, so steppers submit natively.
RTL
Set dir="rtl" and the row mirrors: decrement sits at the right, increment at
the left, and the label aligns to the right. Up and Down keys keep their
meaning. Leave dir unset and the stepper follows the page.
Under dir="rtl" the default names are Arabic: the spinbutton is "الكمية"
and the buttons read زيادة {label} and إنقاص {label}. The label props
still win over them, and strings replaces single entries. For another
right-to-left language, pass every entry in strings.
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "soft" | "outline" | "ghost" | "split" | "joined" | "soft" | Visual variant. pill/floating remain as deprecated aliases of soft/split for one minor |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Density — 32/36/40/48/56px control heights (the input-family ladder, shared with Field/Select); 44px floors on touch screens |
intent | "none" | "primary" | "error" | "warning" | "success" | "info" | "none" | Color intent; error also sets aria-invalid |
tone | "default" | "on-color" | inherited | On-color adapts every ink to a colored plate; inherited from a surrounding Surface |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Radius cascade — container and buttons follow proportionally (square split buttons at sm/md) |
value / defaultValue | number | — / 0 | Controlled / uncontrolled value |
min / max | number | -Infinity / Infinity | Bounds. Steps never exit the range; a value starting outside steps toward it |
step | number | 1 | Per-press change (must be > 0) |
largeStep | number | 10 × step | Applied by PageUp/PageDown and Shift+Arrow |
disabled | boolean | false | Removes the control from the tab order |
readOnly | boolean | false | Focusable-but-frozen; announces aria-readonly, value keeps full contrast |
pending | boolean | false | Async in flight: aria-busy, stepping refused, value pulses |
holdToRepeat | boolean | true | Press-and-hold auto-repeat |
removable | boolean | false | At value <= min the decrement becomes a remove action |
onRemove | () => void | — | Fired by the remove action |
emphasizeIncrement | boolean | false | Fills the + button with the intent color |
showValue | boolean | true | Hide the inline readout (external/hero values). joined never shows one |
formatValue | (value: number) => string | — | Formats the readout AND aria-valuetext |
onValueChange | (value, { reason }) => void | — | Per step; reason ∈ increment-press · decrement-press · hold · keyboard |
onValueCommitted | (value: number) => void | — | Once per settled interaction (pointer release, key release) |
onChange | (value: number) => void | — | Deprecated — use onValueChange |
label / supportingText | string | — | Visible label above / describedby-wired helper below |
errorMessage | ReactNode | — | Error row below. Replaces supportingText, implies intent="error", sets aria-invalid, announced politely; suppressed under readOnly. Passing the prop at all frames the control, so an error can appear without remounting it |
groupLabel | string | "Quantity" | Accessible name when no label — set it for anything that isn't one. Arabic under dir="rtl" |
incrementLabel / decrementLabel / removeLabel | string | composed | Button names; default "Increase/Decrease/Remove {label}", Arabic under dir="rtl" |
strings | Partial<StepperStrings> | English, Arabic under dir="rtl" | Overrides single default names: quantity, increase / decrease / remove (with {label}) and their …Bare forms used when there is no name. The label props win over their entries |
incrementIcon / decrementIcon / removeIcon | ReactNode | +/−/trash | Custom glyphs |
dir | "ltr" | "rtl" | — | Stamped only when set; buttons mirror, Up/Down never flip |
name / form | string | — | Native form participation via a hidden input |
className | string | — | Merged onto the outermost element |
Arbitrary aria-* props land on the spinbutton control; every other rest prop
(data-*, id, style, handlers) lands on the outermost root (§7 split).
StepperSkeleton
variant / size / rounded / showLabel / valueChars — the silhouette
follows the real control's maps (§2b): bars are line-boxes of the value/label
type ladder, and valueChars reserves the width of the widest expected value.
Notes
- Never leave a stepper valueless — always provide
defaultValue(cart-style contexts usually want1). - The value never truncates; it reserves width from the widest bound and
uses
tabular-numsso buttons don't shift as digits change. data-slotattributes (stepper,stepper-decrement,stepper-value,stepper-increment,stepper-status,stepper-label,stepper-supporting-text,stepper-root) plusdata-at-min/at-max/disabled/readonly/pending/variant/sizemake every part and state addressable by tests and agents.
Accessibility
Keyboard
One Tab stop: the control itself. The −/+ buttons are tabindex="-1" — still
reachable for touch and voice AT, redundant for keyboard.
| Key | Action |
|---|---|
Tab | Focus the stepper |
ArrowUp / ArrowDown | ± step |
Shift + Arrow | ± largeStep (default 10 × step) |
PageUp / PageDown | ± largeStep |
Home / End | Jump to min / max (bounded sides only) |
Press-and-hold on the buttons auto-repeats (400ms mouse / 600ms touch delay, then every 60ms); moving the pointer ≥8px cancels the press, and a touch tap steps on release so scrolls that start on a button never change the value.
Accessibility model
The control is a single-tab-stop role="spinbutton" container (the APG
Quantity / Date Picker Spin Buttons shape): it carries
aria-valuenow/min/max/text and the full key set, while the buttons sit at
tabindex="-1" with composed names and aria-controls. Boundary, readOnly
and pending states expose the buttons as aria-disabled — never the
native attribute, which would drop focus to <body> when a boundary is
reached under the cursor. Pointer steps are mirrored into a polite live
region (cleared after 2s); keyboard steps announce through the spinbutton
itself. Negative values announce with a real minus (U+2212). aria-valuetext is
always set: without it VoiceOver + Safari reads a bounded spinbutton as a
percentage of its range, so a quantity of 1 in 0–10 was spoken as "10%".
VoiceOver + Safari: passed 2026-09-14 after that fix — focus reads "1, Quantity, stepper", and the arrow keys read "2", "3", "2". NVDA and JAWS are untested.