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.
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, Astryx included, use the name "Stepper" for
wizards — Tessera does not), and it is not a typing surface: when people should
enter a number, reach for NumberField.
Playground
Installation
pnpm add @tessinaui/uiUsage
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} />When not to use
- Typed entry, large jumps, precise wide ranges →
NumberField. - Approximate values on a continuum →
Slider. - Long fixed option lists (apparel sizes, case-packs) →
Select. - Durations and times →
TimePicker(the market default is a wheel, not a stepper). - Workout/measurement logging → a keypad-friendly
NumberField; steppers are too slow for three-digit entry.
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 the iOS-style 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 phone
viewports.
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. Swap the helper and intent together for validation.
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.
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.
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 phone floors |
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 |
groupLabel | string | "Quantity" | Accessible name when no label — set it for anything that isn't one |
incrementLabel / decrementLabel / removeLabel | string | composed | Button names; default "Increase/Decrease/Remove {label}" |
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).
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).
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.