Forms

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

sm
md
lg

With Custom Buttons

primary
secondary
tertiary
outline

With Format Options

Currency (USD)

Percentage

With Label

Maximum 10 guests per reservation

With Step

Step: 5
Step: 10

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 a flex column with a 6px gap. Hugs its content by default. Carries data-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--defaultgrid-template-areas: "decrement value increment". The default.
  • .number-stepper__group--reversedgrid-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--sm24px button, 0.8125rem type, 14px icon.
  • .number-stepper__group--md28px button, 0.875rem type, 16px icon. The default.
  • .number-stepper__group--lg32px button, 0.9375rem type, 18px icon.

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, so position: absolute would 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 :root definition.
  • .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 when Value is 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, and z-index: 1 so it stays the hit target while a Value sits under it.
  • .number-stepper__increment-button / .number-stepper__decrement-button — The two buttons, each claiming its grid area. These render the @blakeui/react Button, so the shape is ours and the paint can be its. Left unstyled they take the raised-circle treatment; given a variant they 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 set scale or transform here: motion owns that property on this element.
  • .number-stepper__description — The supporting line, var(--muted) at 0.75rem. Tied to the input by aria-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 to opacity: 0.5 and sets cursor: not-allowed on all three parts. The input leaves the tab order with it.
  • :disabled on a button — React Aria reporting that this direction has run out: at a bound, under isReadOnly, or under a disabled root. It is a real disabled attribute, 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 — a brightness() shift, which works on the default circle and on any variant alike.
  • 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 :root definition, 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 --surface is nearly the page itself there and would vanish into the track.
  • --number-stepper-ink / --number-stepper-button-ink--foreground for 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.

PropTypeDefaultDescription
childrenReactNodeA 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.
fullWidthbooleanfalseStretches the control to fill its container instead of hugging its content.
valuenumberThe current value, controlled. Pair with onChange.
defaultValuenumberThe starting value, uncontrolled.
onChange(value: number) => voidFires 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.
minValuenumberThe 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.
maxValuenumberThe upper bound, and the increment button's cut-off. Same treatment.
stepnumber1The 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.
formatOptionsIntl.NumberFormatOptionsShapes 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.
isDisabledbooleanfalseDims 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.
classNamestringClass 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.

PropTypeDefaultDescription
childrenReactNodeThe buttons, the value and the input. Required. Write them in DOM order — decrement, value, input, increment — whatever the layout.
classNamestringClass 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.

PropTypeDefaultDescription
childrenReactNodeThe 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.
classNamestringClass 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.

PropTypeDefaultDescription
children(value: number) => ReactNodeReplaces 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.
prefixstringStatic text before the digits, e.g. "$".
suffixstringStatic text after the digits, e.g. "%". It does not roll — a unit is not a number.
fractionDigitsnumberfrom the field's formatDecimal 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.
scalenumberfrom the field's formatMultiplier 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.
maxValuenumberReserves 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.
classNamestringClass name for the value.

NumberStepper.Input

The typeable field, and the accessible node. Also accepts every native <input> attribute.

PropTypeDefaultDescription
classNamestringClass 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.

PropTypeDefaultDescription
childrenReactNodea plus glyphA 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.
classNamestringClass 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.

PropTypeDefaultDescription
childrenReactNodea minus glyphA custom glyph, wrapped the same way.
variant"primary" | "secondary" | "tertiary" | "outline" | …The same passthrough as the increment button.
classNamestringClass 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.

PropTypeDefaultDescription
childrenReactNodeThe supporting text.
classNamestringClass name for the description.

On this page