WebberUI

Quantity Stepper (React)

A React ± quantity stepper for ecommerce: press-and-hold acceleration, min/max with an at-limit shake, direct typing, a trash icon at zero, and rolling digits, in compact and pill variants.

The −/+ quantity control every cart and product page needs, with the details filled in: one tap steps once, holding the button starts repeating after 0.4s and speeds up after 2s; hitting the min or max shakes the whole control horizontally and disables that side's button; with min set to 0 and allowRemove on, the minus button turns into a trash icon at zero and calls onRemove instead; editable lets users tap the number and type directly (applied and clamped on blur or Enter, Esc cancels, with an IME-composition guard for Chinese and other input methods). Digits roll one column at a time, in the direction of the change; it supports controlled and uncontrolled use, compact and pill variants, and three sizes.

Loading preview…
npx shadcn@latest add https://webberui.com/r/quantity-stepper.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

0
10
<QuantityStepper />

Installation

npx shadcn@latest add https://webberui.com/r/quantity-stepper.json

Or, once registries are configured in components.json, install it as @webberui/quantity-stepper.

Usage

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

// Uncontrolled: defaults to min 1, max 99
<QuantityStepper defaultValue={1} onChange={(n) => console.log(n)} />

// Cart row: controlled, trash icon at zero, direct typing, limit of 3 per order
<QuantityStepper
  value={qty}
  onChange={setQty}
  min={0}
  max={3}
  allowRemove
  onRemove={() => removeItem(id)}
  editable
  variant="pill"
  size="sm"
/>

// Show a unit (screen readers read this text too)
<QuantityStepper formatValue={(n) => `${n} 件`} />

Props

PropTypeDefaultDescription
valuenumberControlled value; when omitted the component is uncontrolled
defaultValuenumber1Initial value in uncontrolled mode, clamped into min–max first
onChange(value: number) => voidCallback when the value changes; the argument is the clamped new value
minnumber1Lower bound
maxnumber99Upper bound
stepnumber1Amount per step; with decimals, display and rounding follow its decimal places
allowRemovebooleanfalseWhen min is 0 and the value reaches zero, the minus button becomes a trash icon and calls onRemove (instead of being disabled)
onRemove() => voidCallback when the trash button is clicked; requires allowRemove and min of 0
editablebooleanfalseTap the number to type directly; applied and clamped on blur or Enter, Esc cancels
variant"compact" | "pill""compact"Look: compact is a bordered square group, pill is a pill track with round buttons
size"sm" | "md" | "lg""md"Size
disabledbooleanfalseDisable all interaction
formatValue(value: number) => stringCustom display text for the value (e.g. with a unit); also used for aria-valuetext
labelsQuantityStepperLabelssee belowaria-labels for the buttons and the value
classNamestringForwarded to the outermost container

QuantityStepperLabels

FieldTypeDefaultDescription
decrementstring"減少數量" (decrease quantity)aria-label for the minus button
incrementstring"增加數量" (increase quantity)aria-label for the plus button
removestring"移除" (remove)aria-label for the trash button
valuestring"數量" (quantity)aria-label for the value (spinbutton)

clampQuantity(n, min, max, step?) and the QuantityStepperVariant, QuantityStepperSize, and QuantityStepperLabels types are also named exports, so the same clamp logic can be reused at the form layer.

How it works

  • Hold timing: pointerdown steps once immediately; after 400ms of holding it steps every 120ms, and 2s after the press it speeds up to every 60ms. pointerup/pointercancel are listened for on window, so sliding off the button or the button becoming disabled mid-hold still stops it; repeating stops at the limit, and every timer is cleared on unmount
  • At-limit shake: only a user action that hits a bound shakes the control (x: [0, -4, 4, -2, 2, 0]); an initial value that already sits at a bound does not shake on load. In remove mode, reaching zero turns the minus into a trash icon rather than hitting a wall, so it does not shake
  • Rolling digits: each character is its own column, keyed by its position from the right, so 9 → 10 rolls only the ones column while the tens column expands in from the left; on increase the new character enters from below, on decrease from above
  • Direct typing: full-width digits are converted to ASCII; non-numeric input discards the change; after applying, focus returns to the value element so keyboard users do not fall through to the body

Accessibility

  • The value element is role="spinbutton" with aria-valuenow/aria-valuemin/aria-valuemax, and aria-valuetext reads the formatValue output; −/+ are native buttons pointing at the value element via aria-controls
  • Keyboard: with focus anywhere inside the control, ↑/↓ step once, PageUp/PageDown step by 10×, Home/End jump to the min/max; when editable, Enter/Space or typing a digit while the value is focused starts editing, and Enter is ignored while an IME (Chinese and others) is composing
  • Because focus stays on the button when stepping with the mouse, a visually hidden aria-live="polite" region announces the new value; the button at a limit is disabled with the native disabled attribute, and the trash button swaps in its own aria-label
  • When the user has "reduce motion" enabled at the system level, the shake, rolling digits, and icon-swap animations are disabled and the number simply updates

On this page