BetaForms

NumberStepper

A quantity, and the two presses that move it.

Overview

A quantity, and the two presses that move it.

First example

<NumberStepper min={0} max={99} defaultValue={1} onValueChange={setQuantity}>
<NumberStepper.Track />
<NumberStepper.Decrement accessibilityLabel="Un de moins" />
<NumberStepper.Value />
<NumberStepper.Increment accessibilityLabel="Un de plus" />
</NumberStepper>

Anatomy

<NumberStepper>
  <NumberStepper.Button />
  <NumberStepper.Track />
  <NumberStepper.Value />
</NumberStepper>

Usage

<NumberStepper min={0} max={99} defaultValue={1} onValueChange={setQuantity}>
  <NumberStepper.Track />
  <NumberStepper.Decrement accessibilityLabel="Un de moins" />
  <NumberStepper.Value />
  <NumberStepper.Increment accessibilityLabel="Un de plus" />
</NumberStepper>

Root props

NumberStepperProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
valuenumber | null | undefinedNoThe number shown. Present means controlled — the stepper follows it, and `onValueChange` is how it asks for a new one. `null` is a stepper with nothing set.
defaultValuenumber | null | undefinedNoWhere an uncontrolled stepper starts.
onValueChange((value: number | null) => void) | undefinedNoEvery press, with the number the stepper now holds. Always inside the bounds.
minnumber | undefinedNoThe floor. Unset, it runs down as far as it is pressed.
maxnumber | undefinedNoThe ceiling.
stepnumber | undefinedNoHow far one press moves the value.
formatOptionsNumberFormatOptions | undefinedNoHow the value is written — a unit, a currency, a fixed number of decimals.
localestring | undefinedNoWhich characters group and separate the number.
variantNumberStepperVariant | undefinedNo
sizeSize | undefinedNoThe buttons' diameter, the pill's height, the gaps and the type. Never width.
radiusRadiusKey | undefinedNoOverrides the circle the buttons are, and the pill's own corner.
colorstring | undefinedNoA raw tint (R7), never a token. It lands where the variant put its tokens.
isDisabledboolean | undefinedNo
styleStyleProp<ViewStyle>No
childrenReactNodeNo

Slots

NumberStepperButtonProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
accessibilityLabelstring | undefinedNoWhat a screen reader says. There is no default, for the reason the close button's has none: a plus is not text, and the label beside the stepper names the quantity rather than what pressing it does.
childrenReactNodeNoReplaces the drawn mark — an icon of your own. A `Decrement` that becomes a bin at the floor is this prop and a ternary, not a prop of its own.
styleStyleProp<ViewStyle>No

NumberStepperTrackProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

NumberStepperValueProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNode | ((value: number | null) => ReactNode)NoGiven the number the stepper holds, so a caller can write it their own way. Without one it is the value formatted, and an em dash while there is none.

Variants, sizes and colour

The variant paints the buttons

Because they are what a finger is aimed at. The pill under them is the ground they are raised off, and it is the same soft grey at every level that has a pill at all.

variantthe buttonsthe pill
primarythe accent, filledyes
secondaryraised surface, hairline borderyes
tertiarythe border alone, no fill and no liftyes
ghostneitherno

secondary is the default, and it is the one thing here that departs from the vocabulary table: default is the token secondary usually names, and a default button on a defaultSoft pill is two greys a shade apart, in which the button stops reading as raised at all.

There are no intents. A stepper reports nothing — it is a quantity being set, not a verdict — so success, warning and danger are absent, exactly as they are on the Card and the TextField.

Accessibility

The source defines no extra rule. Keep the React Native labels, roles, states and focus order your case needs, then check the result with VoiceOver and TalkBack.

Migration from legacy

This component declares no rule of its own. The migration guide covers variants, colours and slots.

Implementation notes

It is not a `NumberField` without its box

A field is typed into and this is not. It has no keyboard, no caret, no parse, no isInvalid and no bounds to apply late — a value that can only be pressed into existence is inside its range at every moment.

What the two share is the arithmetic, and nothing else. stepNumber, clampNumber and their rounding live in utils/number.ts, so neither component owns them and both read the same answers.

you wantreach for
a small number, and the presses are the pointNumberStepper
a number long enough that pressing to it is absurdNumberField
where it sits in its range, more than its exact valueSlider

The track is a slot, and it is written first

It is out of flow and painted behind everything after it, so its place in the JSX is what puts it under the rest rather than over it. Slider.Track's arrangement, and its reason: a ground a caller cannot leave out is a ground a caller cannot replace either.

The inset is the shape. The buttons are the control's full height and the pill is shorter, so the two circles stand proud of the ground between them. A pill as tall as its buttons is a segmented control, which says "pick one" rather than "more of it".

It takes no touches and is hidden from the accessibility tree — a ground is not a control.

Each button owns its own press state

Two buttons on one control are two targets: pressing the plus must not light the minus. That is why the recipe has no bgPressed and the press is the shared PressableFeedback treatment instead — the CloseButton's arrangement, for the CloseButton's reason.

A spent button fades its mark and keeps its fill, and stops taking presses. The mark and not the box, because a button dimmed whole goes translucent — and a translucent button stops hiding the pill it is raised off, so the ground reads straight through the circle. It is also the wrong thing to say: the affordance is gone, the button is still there.

Which end is spent is asked of the result rather than of the bound: a value half a step short of the ceiling can still reach it, and a button dead at that point strands the reader.

With no children each draws its own mark — one bar, or two a quarter turn apart — so a stepper works in a project that has installed no icon set.

A bin at the floor is `children` and a ternary

<NumberStepper.Decrement
  accessibilityLabel={quantity === 1 ? 'Retirer du panier' : 'Un de moins'}
  onPress={quantity === 1 ? remove : undefined}
>
  {quantity === 1 ? <Icon as={TrashIcon} color={theme.colors.danger} /> : undefined}
</NumberStepper.Decrement>

Not a prop of its own. The condition is the caller's — it is a basket row's rule, not a stepper's — and both halves of it are already props this slot has.

A caller's own onPress replaces the step rather than running beside it: removing the row is not also decrementing it.

The value

With no children it is the number written through formatOptions, and an em dash while there is none — an em dash and not a zero, because a stepper that has never been pressed is holding nothing rather than holding none.

A function child is given the number itself, for a unit, a plural, or a word in place of a digit:

<NumberStepper.Value>
  {value => `${value ?? 0} ${value === 1 ? 'convive' : 'convives'}`}
</NumberStepper.Value>

It keeps the page's own ink whatever the buttons are painted, because it sits on the pill rather than on a button.

The number gives way, and never lays over a button

Value shrinks and then truncates: it is the part of the control that can afford to lose room, because the buttons are touch targets. And the buttons carry a zIndex above it, so a number that has run out of room can never end up painted on top of the leading circle — which it otherwise would, since it is written between the two.

One line, always. A second line is a row taller than its own buttons. Pass numberOfLines={0} if a Value of yours really has to wrap.

Width

There is no fullWidth. The root hugs its content, and alignSelf is a style prop (R14) like any other:

<NumberStepper alignSelf="stretch">
  <NumberStepper.Track />
  <NumberStepper.Decrement accessibilityLabel="Un de moins" />
  <NumberStepper.Value flex={1} />
  <NumberStepper.Increment accessibilityLabel="Un de plus" />
</NumberStepper>