Number Stepper
A typeable numeric field with a decrement and an increment button attached — a segmented pill whose value rolls, with every step, clamp and format behaviour taken from React Aria.
Usage
NumberStepper is for a bounded quantity you both nudge and type: a cart quantity, a guest count, a
zoom level, a price. It is a React Aria
NumberField underneath, so stepping,
clamping, step rounding, hold-to-repeat and the formatOptions round trip are all the library's.
The value rolls, and the field is still typeable: Value and Input share a grid cell and swap when
you start editing.
Anatomy
Import the NumberStepper component and access all parts using dot notation.
import {NumberStepper} from "@blakeui/pro-react";
<NumberStepper>
<NumberStepper.Label />
<NumberStepper.Group>
<NumberStepper.DecrementButton />
<NumberStepper.Value />
<NumberStepper.Input />
<NumberStepper.IncrementButton />
</NumberStepper.Group>
<NumberStepper.Description />
</NumberStepper>;Write the children in this order whatever the layout, and keep Value immediately before Input —
the swap between them is an adjacent-sibling rule.
Guest Picker
Adults
Ages 13 or above
Children
Ages 2–12
Infants
Under 2
Pets
Bringing a service animal?
Controlled
Value: 5
Custom Icons
Custom Value
Disabled
Min Max Values
Min: 0, Max: 5
Min: -10, Max: 10
Reversed Layout
Sizes
With Custom Buttons
With Format Options
Currency (USD)
Percentage
With Label
With Step
CSS Classes
The root here is .number-stepper, deliberately not .number-field. @blakeui/react already ships
a NumberField and @blakeui/styles owns .number-field, whose group is a hard-coded
grid-template-columns: 40px 1fr 40px with field elevation and no size scale. Free and Pro share no
class name here, the same split EmptyState (free) and EmptyPanel (Pro) already settled. Nothing
in the free layer targets these names, so every selector below is a single class rather than the
doubled-up specificity a restyled free component would need.
The shape is a segmented pill: a tinted track with two raised circular buttons inset into it. That treatment is not invented here — it is what the Data Grid's editable-cells rollout stepper already ships and what the docs viewport toggle uses.
Base Classes
.number-stepper— The root. Declares every custom property below and stacks label, group and message as aflexcolumn with a6pxgap. Hugs its content by default. Carriesdata-slot="number-stepper"..number-stepper--full-width— Stretches the root to fill its container..number-stepper__group— The pill track, and the only box the modifiers land on.display: grid, fully rounded,var(--number-stepper-inset)of padding,background-color: var(--number-stepper-track). Flat: no border, no shadow — the elevation in this component sits on the buttons.
Layout Classes
Both land on the group, and both are pure re-placement — the DOM order is always decrement, value, input, increment.
.number-stepper__group--default—grid-template-areas: "decrement value increment". The default..number-stepper__group--reversed—grid-template-areas: "increment value decrement". The same three columns, the two buttons swapped.
minmax(0, 1fr) rather than a bare 1fr on the value column is what keeps a long formatted value —
a currency string at a wide locale — from pushing the buttons out of the track; 1fr floors at the
content's min-content width.
Size Classes
Each retunes the button diameter, the type size and the icon together. The button is a circle, so
--number-stepper-control is its target on both axes and every scale clears 24px.
.number-stepper__group--sm—24pxbutton,0.8125remtype,14pxicon..number-stepper__group--md—28pxbutton,0.875remtype,16pxicon. The default..number-stepper__group--lg—32pxbutton,0.9375remtype,18pxicon.
Element Classes
.number-stepper__label— The field's accessible name, a real<label>React Aria ties to the input, so pressing it focuses the field..number-stepper__value— The display cell. Shares its grid area with the input, which is how the two overlap without absolute positioning — the input is the value's sibling, soposition: absolutewould resolve against the nearest positioned ancestor and escape the cell entirely.aria-hidden..number-stepper__odometer— The rolling number itself, rendered by the shared Odometer. Its wheels are clipped by--ac-od-step, which the root re-declares because that variable has no:rootdefinition..number-stepper__suffix— Static text beside the digits — a unit or a symbol. It does not roll, because a unit is not a number. Also carries a fully custom value whenValueis given children..number-stepper__input— The typeable field. Stripped bare — no border, no background, no shadow, no outline — because the track carries all of those.font-variant-numeric: tabular-nums, andz-index: 1so it stays the hit target while aValuesits under it..number-stepper__increment-button/.number-stepper__decrement-button— The two buttons, each claiming its grid area. These render the@blakeui/reactButton, so the shape is ours and the paint can be its. Left unstyled they take the raised-circle treatment; given avariantthey take that variant's fill and the raised-circle rule backs off through:not([data-variant])..number-stepper__icon— The glyph wrapper, and the element the press animation runs on. Nothing in CSS may setscaleortransformhere: motion owns that property on this element..number-stepper__description— The supporting line,var(--muted)at0.75rem. Tied to the input byaria-describedby.
States
State is carried by data attributes React Aria already sets, plus the buttons' own real disabled
attribute — no extra classes.
[data-disabled="true"]on the root — drops the whole control toopacity: 0.5and setscursor: not-allowedon all three parts. The input leaves the tab order with it.:disabledon a button — React Aria reporting that this direction has run out: at a bound, underisReadOnly, or under a disabled root. It is a realdisabledattribute, not a class: the button stops dispatching press, cannot take focus even programmatically, and announces as disabled. It also loses its elevation, because a raised pill that cannot be pressed is a lie about what it does.[data-hovered="true"]/[data-pressed="true"]on an enabled button — abrightness()shift, which works on the default circle and on anyvariantalike.- Focus — the ring goes on the track when the input is focused, because the input fills a cell of the pill and ringing the bare cell would draw a rectangle inside a capsule. A focused button keeps its own ring.
Both stepper buttons carry tabindex="-1" at all times — that is React Aria's doing, and it is the
standard keyboard model for this control: the input is the single tab stop and the arrow keys do the
stepping, so tabbing never lands on a button whether it is disabled or not.
CSS Variables
All declared on .number-stepper and re-declared by the size modifiers, so overriding one on the
root retunes the whole component.
--number-stepper-control— The button's diameter.24px/28px/32px.--number-stepper-inset— The track's padding around the buttons.4px.--number-stepper-gap— The gap between the cells.4px.--number-stepper-font-size— Type size for the label and the value.--number-stepper-icon-size— The glyph.--number-stepper-value-width— The value cell's minimum width, which is what keeps the pill from resizing as digits are gained.--ac-od-step— The Odometer's wheel-cell height and per-cell translate step, in one variable so the roll distance stays locked to the cell.1.25em, the same value the chart roots use. It has no global:rootdefinition, so a component reusing the Odometer must re-declare it or the wheels render unclipped.--number-stepper-track/--number-stepper-button— The pill's fill and the button's. The button reads white against the track in light; in dark it takes--background, because--surfaceis nearly the page itself there and would vanish into the track.--number-stepper-ink/--number-stepper-button-ink—--foregroundfor both.
Motion
Two animations, owned by different systems.
The press affordance is a motion animation on the icon span rather than a CSS transition —
scale reaches it as an animate target and is never written as a static style value or as a
Tailwind scale-* utility, both of which would be fighting motion for the same property. The timing
is the settled asymmetric press: the press-in snaps (duration: 0) to scale: 0.82, and only
the release eases back over 120ms on var(--ease-out). A symmetric press lags under the finger.
The value roll is the shared Odometer. At rest every wheel shows its exact integer digit; the wheels interpolate only while the spring is in flight, then ease back to a crisp integer the instant it settles. Leading non-significant wheels fade rather than pop, so the pill never changes width.
Everything else transitions colour only — the track, the buttons' fill and ink — which under the settled rule needs no opt-out at all: there is no movement in it to remove.
The reduced-motion opt-out therefore covers the scale and the roll, and covers both halves in JS,
because a motion animation cannot be reached by a CSS media query: useReducedMotion handles
prefers-reduced-motion: reduce, and a closest('[data-reduce-motion="true"]') probe handles the
explicit hook, matching that selector's own reach of the element or any ancestor. A
MutationObserver keeps a live toolbar toggle honest, and the probe runs in a layout effect so a
reduced-motion reader never sees even one un-settled frame. Under either signal the scale pins to 1
and the odometer jumps rather than rolls, while the press still reads through its background change
— gentler, not zero.
API Reference
NumberStepper
The root. Renders a React Aria NumberField and hands its parts the resolved layout and size. Also
accepts every prop NumberField takes, including name, isRequired, isReadOnly, isInvalid,
autoFocus, onBlur and validationBehavior.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | A NumberStepper.Group, plus the label and any messages. Required. |
layout | "default" | "reversed" | "default" | Which end each button sits on. Pure CSS re-placement — the DOM order, the tab order and the accessibility tree are the same either way. |
size | "sm" | "md" | "lg" | "md" | Control scale. Retunes the button diameter, the type size and the icon together; the button is a circle, so every scale clears a 24px target on both axes. |
fullWidth | boolean | false | Stretches the control to fill its container instead of hugging its content. |
value | number | — | The current value, controlled. Pair with onChange. |
defaultValue | number | — | The starting value, uncontrolled. |
onChange | (value: number) => void | — | Fires on a step, and on blur once a typed value has been parsed and clamped — not on every keystroke, so a half-typed number never reaches your state. |
minValue | number | — | The lower bound. Clamps a typed value on blur, and disables the decrement button once there is no room left below — a real disabled attribute, so the button stops dispatching press and announces as disabled. |
maxValue | number | — | The upper bound, and the increment button's cut-off. Same treatment. |
step | number | 1 | The increment. It also quantises typed input: on blur the value snaps to the nearest step from minValue, so the field can only ever land on a number you would accept. |
formatOptions | Intl.NumberFormatOptions | — | Shapes the field itself, not a label beside it — the symbol is inside the value being typed, and parses back out of it. A percent formatter scales the underlying number, so {style: "percent"} at step={0.05} shows 15% for a value of 0.15. Drop Value when the result is not an unsigned integer. |
isDisabled | boolean | false | Dims the whole control, disables both buttons, and takes the input out of the tab order — which empties the control's tab stops, since the buttons are never in it. |
className | string | — | Class name for the root. |
NumberStepper.Group
The pill track holding the buttons and the value, and the box both modifiers land on. Also accepts
every native <div> attribute.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The buttons, the value and the input. Required. Write them in DOM order — decrement, value, input, increment — whatever the layout. |
className | string | — | Class name for the group. |
NumberStepper.Label
The field's accessible name. Renders a real <label> React Aria ties to the input, so pressing it
focuses the field. Also accepts every prop React Aria's Label takes.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The label text. Required — it names the field, and the two buttons build their own names from it ("Increase Guests"). Leave the part out only if you put an aria-label on the root instead. |
className | string | — | Class name for the label. |
NumberStepper.Value
The rolling number — the shared Odometer, reading the live value off React Aria's state so nothing is
threaded through by hand. It does not replace Input: the two share a grid cell and swap when an
edit starts, because an odometer is a stack of digit wheels and cannot live inside a text input. The
input stays the accessible node and the hit target, and this part is aria-hidden, so nothing is
announced twice. Must sit immediately before NumberStepper.Input.
| Prop | Type | Default | Description |
|---|---|---|---|
children | (value: number) => ReactNode | — | Replaces the rolling number entirely, receiving the live value. The trade is the roll: a fully custom string is rendered as-is. For a unit or a symbol reach for suffix / prefix instead, which keep it. |
prefix | string | — | Static text before the digits, e.g. "$". |
suffix | string | — | Static text after the digits, e.g. "%". It does not roll — a unit is not a number. |
fractionDigits | number | from the field's format | Decimal places to roll. Resolved from the field's formatOptions through Intl, so a currency format supplies its two decimals without being asked. The digits scale into integer space, so no wheel ever divides by a fraction. |
scale | number | from the field's format | Multiplier applied before the digits render, for a formatter whose display scale differs from the underlying number. A percent field holds 0.5 and shows 50, so this resolves to 100 for style: "percent" and 1 otherwise. |
maxValue | number | — | Reserves wheels up to this bound so the pill cannot resize as digits are gained. Opt-in, and deliberately not defaulted to the field's own maxValue: a reserved wheel keeps its slot even while faded, so a field bounded at 99 showing 1 rendered a hidden tens wheel that pushed the visible digit 4px off the centre. Width stability comes from the value cell's min-width instead. |
className | string | — | Class name for the value. |
NumberStepper.Input
The typeable field, and the accessible node. Also accepts every native <input> attribute.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Class name for the input. The element is a text input with inputMode="numeric", not type="number", which is what lets a currency symbol sit inside it and still parse back to a number. React Aria deliberately leaves role="spinbutton" and aria-valuenow off it — a spin button cannot be focused under VoiceOver — and announces the field through aria-roledescription instead. |
NumberStepper.IncrementButton
The step-up button. React Aria supplies the press handler, the hold-to-repeat timer and the bound
state through slot="increment" — there is no handler to pass. Renders the @blakeui/react
Button, so it also accepts that component's props.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | a plus glyph | A custom glyph. Whatever you pass is wrapped in .number-stepper__icon, so it takes the press animation too. |
variant | "primary" | "secondary" | "tertiary" | "outline" | … | — | Handed straight to the @blakeui/react Button. Left unset the button takes this component's own raised-circle treatment; set, that variant's paint comes through and the circle rule backs off. The shape stays ours either way. |
className | string | — | Class name for the button. |
NumberStepper.DecrementButton
The step-down button, wired through slot="decrement". Same shape as the increment button, and it
defaults to a minus glyph.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | a minus glyph | A custom glyph, wrapped the same way. |
variant | "primary" | "secondary" | "tertiary" | "outline" | … | — | The same passthrough as the increment button. |
className | string | — | Class name for the button. |
NumberStepper.Description
The supporting line under the control. React Aria's Text in the description slot, so it is tied
to the input by aria-describedby rather than merely sitting near it.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The supporting text. |
className | string | — | Class name for the description. |
Native Select
A form field built on a real <select> — the platform's own menu on a phone, the platform's keyboard model everywhere, and no JavaScript needed to make it accessible.
Radio Button Group
A single-select group whose options are cards, built on a real radio group with roving focus, arrow-key selection, and a per-item indicator.