DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
BreadcrumbTabsPaginationStepperMenu ItemToolbarMenubarNavigation MenuAccordionCollapsibleCommandBottom NavTop Header MobileTop Header DesktopSidebar
AI agentsContributing
  1. Components
  2. Stepper

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/ui

When 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 want 1) so it is never valueless, and set min and max so the exhausted button dims in place.
  • A quantity in a cart row. With removable, the decrement becomes a remove action at min, so one control covers "fewer" and "none".
  • A value that carries a unit. formatValue renders it and feeds aria-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

Terminal
pnpm add @tessinaui/ui

Usage

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.

example.tsx
import { Stepper } from "@tessinaui/ui";
example.tsx
{/* 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

PropTypeDefaultDescription
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"inheritedOn-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 / defaultValuenumber— / 0Controlled / uncontrolled value
min / maxnumber-Infinity / InfinityBounds. Steps never exit the range; a value starting outside steps toward it
stepnumber1Per-press change (must be > 0)
largeStepnumber10 × stepApplied by PageUp/PageDown and Shift+Arrow
disabledbooleanfalseRemoves the control from the tab order
readOnlybooleanfalseFocusable-but-frozen; announces aria-readonly, value keeps full contrast
pendingbooleanfalseAsync in flight: aria-busy, stepping refused, value pulses
holdToRepeatbooleantruePress-and-hold auto-repeat
removablebooleanfalseAt value <= min the decrement becomes a remove action
onRemove() => void—Fired by the remove action
emphasizeIncrementbooleanfalseFills the + button with the intent color
showValuebooleantrueHide 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 / supportingTextstring—Visible label above / describedby-wired helper below
errorMessageReactNode—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
groupLabelstring"Quantity"Accessible name when no label — set it for anything that isn't one. Arabic under dir="rtl"
incrementLabel / decrementLabel / removeLabelstringcomposedButton names; default "Increase/Decrease/Remove {label}", Arabic under dir="rtl"
stringsPartial<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 / removeIconReactNode+/−/trashCustom glyphs
dir"ltr" | "rtl"—Stamped only when set; buttons mirror, Up/Down never flip
name / formstring—Native form participation via a hidden input
classNamestring—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 want 1).
  • The value never truncates; it reserves width from the widest bound and uses tabular-nums so buttons don't shift as digits change.
  • data-slot attributes (stepper, stepper-decrement, stepper-value, stepper-increment, stepper-status, stepper-label, stepper-supporting-text, stepper-root) plus data-at-min/at-max/disabled/readonly/pending/variant/size make 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.

KeyAction
TabFocus the stepper
ArrowUp / ArrowDown± step
Shift + Arrow± largeStep (default 10 × step)
PageUp / PageDown± largeStep
Home / EndJump 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.

PaginationMenu Item

On this page

When to useWhen not to useInstallationUsageExamplesDefaultVariantsSizesIntentsStatesWith label and validationCart row (removable)Guest counter panelAdd → stepperSheet footer with CTAFormatted valuesExternal valueOn colorIn a formRTLAPI ReferencePropsStepperSkeletonNotesAccessibilityKeyboardAccessibility model