v0.5

Input Stepper

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="connected" for compact form rows where the buttons should read as part of the input shell; use variant="separated" when the buttons need more tap-area on touch surfaces or should visually detach from the cell.

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

pnpm dlx @create-ui/cli add input-stepper

Usage

import { InputStepper } from "@/components/ui/input-stepper"
<InputStepper defaultValue={1} min={0} max={10} />

Examples

Variants

variant="connected" (default) shares one shell with bordered buttons that read as part of the input cell. variant="separated" renders three pieces with a gap, which lifts the buttons into their own tap targets, useful on touch surfaces.

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.

Default
Filled
Invalid
Disabled

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.

Seats (1–10, step 1)
Tickets (0–20, step 2)

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.

Inherits size from <Field size="md" />.

Inherits invalid from Field.

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.

Value: 3

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">.

KeyDescription
TabMoves focus to the input cell. The - / + buttons are skipped by design.
ArrowUpSteps the value up by step, clamped to max (native input behavior).
ArrowDownSteps the value down by step, clamped to min (native input behavior).
EnterSubmits the surrounding form when present.

ARIA notes:

  • Decrement and increment buttons set aria-label; override the defaults with the decrementLabel and incrementLabel props.
  • The separated variant wraps the trio in a role="group" element so assistive tech announces it as a single control.
  • The input forwards aria-invalid when invalid is true directly or inherited from a parent Field.
  • 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 id and use FieldLabel htmlFor (see the Field composition example above).

Styling

Tailwind override: pass className to constrain or restyle the root via cn():

<InputStepper className="w-[240px]" />

Data slots and attributes: the component sets these for CSS targeting:

  • data-slot="input-stepper" on the root (InputShell for connected, div for separated).
  • data-variant="connected" | "separated" on the root.
  • data-slot="input-stepper-decrement" and data-slot="input-stepper-increment" on the buttons.
  • data-slot="input-stepper-cell" on the center cell.
  • data-slot="input-stepper-control" on the inner <input>.
  • data-slot="input-stepper-cell-shell" on the wrapper shell (separated variant only).
  • Inherited from Field via the input shell context: data-size, data-invalid, data-disabled, data-loading.

Target a sub-part in CSS:

[data-slot="input-stepper"][data-variant="separated"]
  [data-slot="input-stepper-decrement"] {
  /* … */
}
  • 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, and disabled automatically.
  • 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>.

Props

PropTypeDefaultDescription
valuenumber-Controlled current value. Clamped to [min, max].
defaultValuenumber-Uncontrolled initial value. Clamped to [min, max].
onValueChange(value: number) => void-Fires when the user commits a new clamped value via input, +/-, or arrow keys.
minnumber-InfinityLower bound. The decrement button auto-disables when the current value is <= min.
maxnumberInfinityUpper bound. The increment button auto-disables when the current value is >= max.
stepnumber1Increment applied per +/- click. Native arrow keys use the same step.
invalidboolean-Marks the field as invalid; sets aria-invalid and switches text/placeholder to error tone.
disabledboolean-Disables the input and both buttons; cascades through the input context.
decrementLabelstring"Decrease"aria-label for the decrement button.
incrementLabelstring"Increase"aria-label for the increment button.
classNamestring-Tailwind classes merged on the root shell/wrapper via cn().

Variants

VariantOptionsDefaultDescription
variant"connected" "separated""connected"connected shares one shell with bordered buttons; separated renders three pieces with a gap.
size"xs" "sm" "md""sm"Height, padding, and type scale. Inherits from a parent Field when present.