Numeric input with attached increment and decrement buttons for tight quantity controls.
Description
InputStepper is a number-only input flanked by - and + buttons. It renders a focusable <input type="number"> at its center and shares the input shell context with its sub-parts, so size, invalid, and disabled cascade from a parent Field without manual prop drilling.
Reach for it when the value is a bounded count: cart line items, seat selectors, batch sizes, font weights, page numbers. Use variant="split" for compact form rows where the buttons should read as part of the input shell; use variant="detached" when the buttons need more tap-area on touch surfaces or should visually detach from the cell. The free tier ships split and detached; a developer seat unlocks the end-controls and vertical-stepper layouts plus an inline prefix slot for a unit or currency mark.
Don't use it for free-form numeric entry without bounds; drop down to Input with type="number". For wide ranges chosen by feel rather than precise value, use a slider. For binary on/off, use Switch or Checkbox. If the value is picked from a fixed list, use Select.
Installation
Usage
Examples
InputStepper is tiered: the free component ships the split and detached layouts, and add input-stepper with a developer seat swaps in the Pro build that adds the end-controls and vertical-stepper layouts plus a prefix slot. The API is otherwise identical, so upgrading is a variant swap, not a rewrite.
Variants
variant="split" (default) shares one shell with bordered buttons that read as part of the input cell. variant="detached" renders three pieces with a gap, which lifts the buttons into their own tap targets, useful on touch surfaces.
Pro adds two trailing-edge layouts: end-controls left-aligns the value and parks both buttons at the trailing edge, while vertical-stepper stacks increment and decrement into a chevron column for the most compact footprint.
Animated value
Pro animates the value per digit: stepping rolls only the digits that actually change, so 10 → 11 spins the trailing 0 up to 1 and leaves the leading 1 still. The display is powered by Number Flow, which add input-stepper installs for you on a developer seat. Focus the field and the animated display steps aside for the real input so typing still works; blur it and the animation comes back. The free tier renders the value statically, with no animation.
Sizes
Three sizes (xs, sm, md) control the cell height, padding, and type scale, plus the button size that pairs with it. sm is the default.
States
invalid and disabled flow through the input context to the cell, the input, and both buttons. The default row is empty so the placeholder shows through in placeholder color.
Bounded range
Set min, max, and step to clamp the value and tick by a custom interval. The decrement button auto-disables when the current value is <= min and the increment button when it's >= max.
Prefix
The free stepper holds a bare number. A developer seat adds the prefix slot: a display-only leading node inside the cell for a unit or currency mark. It tracks invalid and disabled tone automatically and never becomes part of the submitted value.
Composition with Field
Wrap the stepper in Field to add a label and helper text. size, invalid, and disabled on Field cascade to the stepper through the shared input context, so the inner control never has to repeat those props.
Controlled
Drive the value from state by combining value and onValueChange. The callback fires with the clamped result of every commit, whether the source is typed entry, a +/- click, or a native arrow-key step.
Accessibility
Focus stays on the input cell: the - / + buttons are tabIndex={-1} and call preventDefault on mousedown so a click fires without stealing focus. Arrow-key stepping is owned by the native <input type="number">.
ARIA notes:
- Decrement and increment buttons set
aria-label; override the defaults with thedecrementLabelandincrementLabelprops. - The detached variant wraps the trio in a
role="group"element so assistive tech announces it as a single control. - The input forwards
aria-invalidwheninvalidistruedirectly or inherited from a parentField. - Buttons keep focus on the input via
onMouseDown={preventDefault}. This is intentional, not a bug: the focus ring stays on the cell while clicks still fire. - For label association, pass an
idand useFieldLabel htmlFor(see theFieldcomposition example above).
Styling
Tailwind override: pass className to constrain or restyle the root via cn():
Data slots and attributes: the component sets these for CSS targeting:
data-slot="input-stepper"on the root (InputGroupShellforsplit,divfordetached).data-variant="split" | "detached"on the root.data-slot="input-stepper-decrement"anddata-slot="input-stepper-increment"on the buttons.data-slot="input-stepper-cell"on the center cell.data-slot="input-stepper-prefix"on the leadingprefixnode (Pro only).data-slot="input-stepper-control"on the inner<input>.data-slot="input-stepper-cell-shell"on the wrapper shell (detached variant only).- Inherited from
Fieldvia the input shell context:data-size,data-invalid,data-disabled,data-loading.
Target a sub-part in CSS:
Related Components
- Input: free-form text or numeric entry without bounds or stepper buttons.
- Field: wraps the stepper to add label, description, and error footer; cascades
size,invalid, anddisabledautomatically. - InputGroup: a separate composite for building custom adornment clusters (icons, addons, buttons) around
Input.
API Reference
InputStepper
Numeric stepper input. Extends React.ComponentProps<"input"> minus size, value, defaultValue, and onChange (managed internally), so standard input attributes like id, name, placeholder, required, and aria-* flow through to the inner <input>.