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.
npx shadcn@latest add https://webberui.com/r/quantity-stepper.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<QuantityStepper />
Installation
npx shadcn@latest add https://webberui.com/r/quantity-stepper.jsonOr, 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
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | Controlled value; when omitted the component is uncontrolled |
defaultValue | number | 1 | Initial value in uncontrolled mode, clamped into min–max first |
onChange | (value: number) => void | — | Callback when the value changes; the argument is the clamped new value |
min | number | 1 | Lower bound |
max | number | 99 | Upper bound |
step | number | 1 | Amount per step; with decimals, display and rounding follow its decimal places |
allowRemove | boolean | false | When min is 0 and the value reaches zero, the minus button becomes a trash icon and calls onRemove (instead of being disabled) |
onRemove | () => void | — | Callback when the trash button is clicked; requires allowRemove and min of 0 |
editable | boolean | false | Tap 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 |
disabled | boolean | false | Disable all interaction |
formatValue | (value: number) => string | — | Custom display text for the value (e.g. with a unit); also used for aria-valuetext |
labels | QuantityStepperLabels | see below | aria-labels for the buttons and the value |
className | string | — | Forwarded to the outermost container |
QuantityStepperLabels
| Field | Type | Default | Description |
|---|---|---|---|
decrement | string | "減少數量" (decrease quantity) | aria-label for the minus button |
increment | string | "增加數量" (increase quantity) | aria-label for the plus button |
remove | string | "移除" (remove) | aria-label for the trash button |
value | string | "數量" (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:
pointerdownsteps once immediately; after 400ms of holding it steps every 120ms, and 2s after the press it speeds up to every 60ms.pointerup/pointercancelare listened for onwindow, 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 → 10rolls 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"witharia-valuenow/aria-valuemin/aria-valuemax, andaria-valuetextreads theformatValueoutput; −/+ are nativebuttons pointing at the value element viaaria-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 nativedisabledattribute, 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
Star Rating (React)
A classic star rating React component: half stars, hover-preview fill, arrow and number key control, a bounce on select, and a read-only mode that shows the average, review count and a distribution chart; icon, colour and star count are all customisable.
Magnetic Button
A magnetic button that is pulled towards the cursor as it approaches, with a parallax on the inner text and a spring back once the cursor leaves.