# Quantity stepper

> A React quantity stepper for carts and product pages, with stock hints, hold to repeat, rolling digits and an Add to cart morph.

- Type: React component (Commerce)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add quantity-stepper`
- Docs: https://wingo-ui.com/components/quantity-stepper

## Interaction

Tap or click minus and plus to change the quantity; the new number rolls in from below when it grows and from above when it shrinks. Hold a button and it repeats after 400ms, speeding up, and stops at the limit; on a phone a finger that starts to scroll never changes the value. Pressing past a limit shakes the control (and buzzes on a phone), and at the maximum a line under it says why. With removable, minus turns into a trash button at the minimum; with addToCart the control starts as an Add to cart button that morphs into the stepper and collapses back from the first step. Keyboard: Tab reaches the value (one stop), Up and Down step, Page Up and Page Down move 10 steps, Home and End jump to the minimum and maximum, Delete removes the line at the minimum when removable. With editable, type a number and press Enter or leave the field (Escape drops what was typed); it is snapped to the step and clamped, and pressing plus or minus right after typing steps from the typed number. While the cart syncs a spinner replaces the digits, and a failed save rolls back with a message.

## Installation

```bash
npx wingo-ui@latest add quantity-stepper
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/quantity-stepper` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/quantity-stepper.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { QuantityStepper } from "@/components/ui/quantity-stepper"

export function CartLine({ quantity, onQuantity, onRemove }: { quantity: number; onQuantity: (value: number) => Promise<void>; onRemove: () => void }) {
  return (
    <QuantityStepper
      value={quantity}
      onValueChange={onQuantity}
      removable
      onRemove={onRemove}
      stock={3}
      aria-label="Quantity, iPhone 14 case"
      labels={{
        remove: "Remove from bag",
        lowStock: (_, n) => `Hurry, only ${n} left`,
      }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue?` | `number` | `1` | The starting quantity while uncontrolled. Without it, an addToCart stepper starts at 0 (the button). |
| `value?` | `number` |  | The controlled quantity. A value outside min and max is shown clamped and never reported back. |
| `onValueChange?` | `(value: number) => (void \| Promise<unknown>)` |  | Called with every new quantity. Return a promise (a cart sync) to show the loading state until it settles; a rejection reverts the value and announces labels.error. Controlled: set the value when the promise resolves. |
| `min?` | `number` | `1` | The lowest quantity. With addToCart it is the first step after the button (at least step), and 0 collapses back to the button. |
| `max?` | `number` | `99` | The highest quantity, capped by stock when stock is tracked. A max below min becomes min. |
| `step?` | `number` | `1` | How much one press changes. Decimals work (0.5 kg) without float noise; typed values snap to it. |
| `stock?` | `number` | `0` | Units left. Caps max and shows labels.lowStock at or below lowStockThreshold. A quantity above it is never changed silently: the hint says so and plus stops. 0 means not tracked. |
| `lowStockThreshold?` | `number` | `5` | The stock at or below which the low stock line shows. |
| `removable?` | `boolean` | `false` | At the minimum, minus becomes a red trash button that calls onRemove (Delete on the keyboard does the same). |
| `onRemove?` | `() => void` |  | Called by the trash button. The stepper keeps its value; remove the line (and offer an undo) in your cart. |
| `addToCart?` | `boolean` | `false` | At 0 the control is an Add to cart button in the tone. Pressing it sets the first step and morphs into the stepper; minus at the first step collapses it back. |
| `editable?` | `boolean` | `false` | The value is a text field for bulk quantities: type 120 and press Enter. Clamped and snapped on Enter and blur, numeric keyboard on phones. |
| `holdToRepeat?` | `boolean` | `true` | Holding a button repeats after 400ms, every 100ms speeding up to every 30ms. |
| `suffix?` | `string` | `""` | A unit after the number, like "buc." or "kg". Also read by screen readers. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Height 32 / 40 / 48px with square buttons. md grows to 44px on touch; sm keeps its look with a 44px hit area. |
| `variant?` | `"soft" \| "outline" \| "ghost"` | `"soft"` | The field box look: a filled well, a hairline outline, or nothing until hovered. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"full"` | Corner shape, from the button's per-size map; full is a pill. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The color of the Add to cart button. Brand orange is opt-in for the one hero action of a product page. |
| `color?` | `string` | `""` | Any CSS color for the Add to cart button; replaces the tone. |
| `loading?` | `boolean` | `false` | The cart is syncing: the buttons stop, a spinner takes the digits' place and the width holds still. |
| `disabled?` | `boolean` | `false` | Dims the control and blocks every input. |
| `haptics?` | `boolean` | `true` | On phones: a "selection" tick per step (every third while a hold accelerates) and a "warning" buzz at a limit. Never from a mouse. |
| `locale?` | `string` | `""` | Intl locale for the digits ("1.5" in en-US, "1,5" in de-DE). Empty uses the UIProvider locale, then the page. |
| `labels?` | `Partial<QuantityStepperLabels>` |  | Every string: group "Quantity", increment "Increase quantity", decrement "Decrease quantity", remove "Remove", add "Add to cart", lowStock (count, formatted) => "Only 3 left", overStock (count, formatted) => "Only 3 in stock", max (count, formatted) => "Max. 3 per order", maxReached "Maximum reached", error "Could not update the quantity". DEFAULT_QUANTITY_STEPPER_LABELS exports them. |
| `classNames?` | `Partial<Record<"box" \| "decrement" \| "increment" \| "remove" \| "value" \| "digits" \| "suffix" \| "add" \| "hint", string>>` |  | Extra classes for the inner parts. className goes on the root, which stacks the box and the hint line. |
| `aria-label?` | `string` | `""` | Names the group and the value, e.g. "Quantity, iPhone 14 case" in a cart with many lines. Empty uses labels.group. |

## Parts

### DEFAULT_QUANTITY_STEPPER_LABELS (constant)

The English strings: the group and button names, Add to cart, the stock hints, the per order maximum and the error. Spread it and override what you translate.

## Examples

- **Product card**: addToCart: Add to cart morphs into the stepper, in the brand tone.
- **Cart rows**: removable: at 1 the minus becomes a trash button; the row leaves and Undo brings it back.
- **Low stock**: stock={3}: Only 3 left in stock, then a shake and At most 3 per order.
- **Bulk order**: editable with max={500}: type a quantity of boxes instead of pressing 120 times.
- **By weight**: step={0.5} min={0.5} suffix="lb": decimals formatted for the locale.
- **Sizes and variants**: sm, md and lg in soft, outline and ghost.
- **Syncing**: onValueChange returns a promise: a spinner while it saves, a rollback and a message when it fails.

## See also

- [Number Input](https://wingo-ui.com/components/number-input) ([Markdown](https://wingo-ui.com/components/number-input.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Price](https://wingo-ui.com/components/price) ([Markdown](https://wingo-ui.com/components/price.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))

---

Canonical: https://wingo-ui.com/components/quantity-stepper
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
