Quantity stepper React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React quantity stepper for carts and product pages, with stock hints, hold to repeat, rolling digits and an Add to cart morph.
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.
Quantity stepper is a Pro React component in the React commerce components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add quantity-stepper, together with Button, Spinner, useControllableState, useLocale, Haptics and 4 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add quantity-stepperQuantity stepper is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import QuantityStepper from @/components/ui/quantity-stepper.
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
defaultValuedefault1numberThe starting quantity while uncontrolled. Without it, an addToCart stepper starts at 0 (the button).
valuenumberThe 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.
mindefault1numberThe lowest quantity. With addToCart it is the first step after the button (at least step), and 0 collapses back to the button.
maxdefault99numberThe highest quantity, capped by stock when stock is tracked. A max below min becomes min.
stepdefault1numberHow much one press changes. Decimals work (0.5 kg) without float noise; typed values snap to it.
stockdefault0numberUnits 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.
lowStockThresholddefault5numberThe stock at or below which the low stock line shows.
removabledefaultfalsebooleanAt the minimum, minus becomes a red trash button that calls onRemove (Delete on the keyboard does the same).
onRemove() => voidCalled by the trash button. The stepper keeps its value; remove the line (and offer an undo) in your cart.
addToCartdefaultfalsebooleanAt 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.
editabledefaultfalsebooleanThe value is a text field for bulk quantities: type 120 and press Enter. Clamped and snapped on Enter and blur, numeric keyboard on phones.
holdToRepeatdefaulttruebooleanHolding a button repeats after 400ms, every 100ms speeding up to every 30ms.
suffixdefault""stringA unit after the number, like "buc." or "kg". Also read by screen readers.
sizedefault"md""sm" | "md" | "lg"Height 32 / 40 / 48px with square buttons. md grows to 44px on touch; sm keeps its look with a 44px hit area.
variantdefault"soft""soft" | "outline" | "ghost"The field box look: a filled well, a hairline outline, or nothing until hovered.
radiusdefault"full""none" | "sm" | "md" | "lg" | "full"Corner shape, from the button's per-size map; full is a pill.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The color of the Add to cart button. Brand orange is opt-in for the one hero action of a product page.
colordefault""stringAny CSS color for the Add to cart button; replaces the tone.
loadingdefaultfalsebooleanThe cart is syncing: the buttons stop, a spinner takes the digits' place and the width holds still.
disableddefaultfalsebooleanDims the control and blocks every input.
hapticsdefaulttruebooleanOn phones: a "selection" tick per step (every third while a hold accelerates) and a "warning" buzz at a limit. Never from a mouse.
localedefault""stringIntl locale for the digits ("1.5" in en-US, "1,5" in de-DE). Empty uses the UIProvider locale, then the page.
labelsPartial<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.
classNamesPartial<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-labeldefault""stringNames 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
constantThe 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.
Dependencies
Files
- components/ui/quantity-stepper.tsx
FAQ
Is the React Quantity stepper component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Quantity stepper?
Run npx wingo-ui@latest add quantity-stepper. It also adds the Wingo UI items it builds on (Button, Spinner, useControllableState, useLocale, Haptics and 4 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/quantity-stepper.
How does Quantity stepper behave on phones?
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.
