Inputs & Formsv0.1.0
Quantity Stepper
Minus and plus with a rolling number, clamped to range.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/quantity-stepper.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/quantity-stepper/quantity-stepper.tsx"use client"; import { useEffect, useRef, useState, type ComponentProps, type KeyboardEvent } from "react"; import { AnimatePresence, motion, useAnimate, useReducedMotion, type Variants } from "motion/react"; import { Minus, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface QuantityStepperProps extends Omit<ComponentProps<"div">, "defaultValue" | "onChange" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Controlled value. */ value?: number; /** Initial value when uncontrolled. @default min */ defaultValue?: number; /** Called with the new value after every step. */ onValueChange?: (value: number) => void; /** Lowest value. @default 0 */ min?: number; /** Highest value. @default 99 */ max?: number; /** Amount added or removed per step. @default 1 */ step?: number; /** Control size. @default "md" */ size?: "sm" | "md" | "lg"; /** Accessible name of the value, also used for the button labels. @default "Quantity" */ label?: string; /** Disables the whole control. @default false */ disabled?: boolean; } const sizes = { sm: { group: "gap-0.5 p-0.5", button: "size-7 [&_svg]:size-3.5", value: "min-w-7 text-[13px]" }, md: { group: "gap-1 p-1", button: "size-8 [&_svg]:size-4", value: "min-w-9 text-sm" }, lg: { group: "gap-1.5 p-1.5", button: "size-11 [&_svg]:size-5", value: "min-w-14 text-2xl" }, } as const; const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi); /** Each digit place rolls on its own, up when increasing and down when decreasing. */ function RollingNumber({ value, direction }: { value: number; direction: number }) { const reduceMotion = useReducedMotion(); const chars = String(value).split(""); const roll: Variants = reduceMotion ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } : { initial: (dir: number) => ({ y: dir > 0 ? "70%" : "-70%", opacity: 0, filter: "blur(2px)" }), animate: { y: "0%", opacity: 1, filter: "blur(0px)" }, exit: (dir: number) => ({ y: dir > 0 ? "-70%" : "70%", opacity: 0, filter: "blur(2px)" }), }; const transition = { duration: duration.base, ease: ease.out }; return ( <span className="inline-flex justify-center overflow-hidden leading-[1.3] tabular-nums [mask-image:linear-gradient(transparent,black_22%,black_78%,transparent)]"> <AnimatePresence mode="popLayout" initial={false} custom={direction}> {chars.map((char, i) => { const place = chars.length - 1 - i; return ( <motion.span key={`place-${place}`} custom={direction} variants={roll} initial="initial" animate="animate" exit="exit" transition={transition} className="relative inline-block" > <AnimatePresence mode="popLayout" initial={false} custom={direction}> <motion.span key={char} custom={direction} variants={roll} initial="initial" animate="animate" exit="exit" transition={transition} className="inline-block" > {char} </motion.span> </AnimatePresence> </motion.span> ); })} </AnimatePresence> </span> ); } export function QuantityStepper({ value, defaultValue, onValueChange, min = 0, max = 99, step = 1, size = "md", label = "Quantity", disabled = false, className, ...props }: QuantityStepperProps) { const [internal, setInternal] = useState(() => clamp(defaultValue ?? min, min, max)); const current = clamp(value ?? internal, min, max); const [direction, setDirection] = useState(1); const [scope, animate] = useAnimate<HTMLDivElement>(); const reduceMotion = useReducedMotion(); const latest = useRef(current); const repeat = useRef<number | undefined>(undefined); const s = sizes[size]; useEffect(() => { latest.current = current; }); useEffect(() => () => window.clearTimeout(repeat.current), []); /** Applies a delta; returns false (and nudges the number) when already at a bound. */ function change(delta: number) { const from = latest.current; const next = clamp(Number((from + delta).toFixed(10)), min, max); if (next === from) { if (!reduceMotion && scope.current) { const dir = Math.sign(delta); animate(scope.current, { x: [0, 4 * dir, -2 * dir, 0] }, { duration: 0.3, ease: ease.out }); } return false; } latest.current = next; setDirection(Math.sign(next - from)); if (value === undefined) setInternal(next); onValueChange?.(next); return true; } function stopRepeat() { window.clearTimeout(repeat.current); } // Press and hold to repeat, speeding up the longer it is held. function startRepeat(delta: number) { stopRepeat(); if (!change(delta)) return; let delay = 420; const tick = () => { if (!change(delta)) return; delay = Math.max(45, delay * 0.78); repeat.current = window.setTimeout(tick, delay); }; repeat.current = window.setTimeout(tick, delay); } function onSpinKey(event: KeyboardEvent<HTMLDivElement>) { const big = Math.max(step, Math.round((max - min) / 10 / step) * step); const actions: Record<string, () => unknown> = { ArrowUp: () => change(step), ArrowRight: () => change(step), ArrowDown: () => change(-step), ArrowLeft: () => change(-step), PageUp: () => change(big), PageDown: () => change(-big), Home: () => change(min - latest.current), End: () => change(max - latest.current), }; const action = actions[event.key]; if (!action || disabled) return; event.preventDefault(); action(); } const stepButton = (delta: number) => { const atBound = delta < 0 ? current <= min : current >= max; const Icon = delta < 0 ? Minus : Plus; return ( <button type="button" tabIndex={-1} disabled={disabled} aria-label={`${delta < 0 ? "Decrease" : "Increase"} ${label.toLowerCase()}`} aria-disabled={atBound || undefined} onPointerDown={(event) => { if (event.button !== 0) return; event.preventDefault(); startRepeat(delta); }} onClick={(event) => { // Clicks from assistive tech arrive without a pointerdown (detail is 0). if (event.detail === 0) change(delta); }} onPointerUp={stopRepeat} onPointerLeave={stopRepeat} onPointerCancel={stopRepeat} onContextMenu={(event) => event.preventDefault()} className={cn( "relative inline-flex shrink-0 cursor-pointer touch-none items-center justify-center rounded-full text-foreground outline-none select-none", "transition-[background-color,color,transform,opacity] duration-150 after:absolute after:-inset-1 hover:bg-accent active:scale-90", "aria-disabled:cursor-default aria-disabled:text-muted-foreground aria-disabled:opacity-50 aria-disabled:hover:bg-transparent aria-disabled:active:scale-100", s.button, )} > <Icon strokeWidth={2.25} aria-hidden="true" /> </button> ); }; return ( <div role="group" aria-label={label} className={cn( "inline-flex items-center rounded-full border border-input bg-background shadow-xs", disabled && "pointer-events-none opacity-50", s.group, className, )} {...props} > {stepButton(-step)} <div ref={scope} role="spinbutton" tabIndex={disabled ? -1 : 0} aria-label={label} aria-valuenow={current} aria-valuemin={min} aria-valuemax={max} aria-disabled={disabled || undefined} onKeyDown={onSpinKey} className={cn( "flex items-center justify-center rounded-md px-1 font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring", s.value, )} > <RollingNumber value={current} direction={direction} /> </div> {stepButton(step)} </div> ); }
Usage
import { QuantityStepper } from "@/components/ui/quantity-stepper/quantity-stepper";
export function CartItem() {
const [quantity, setQuantity] = useState(1);
return <QuantityStepper value={quantity} onValueChange={setQuantity} min={1} max={10} />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | – | Controlled value. |
defaultValue | number | min | Initial value when uncontrolled. |
onValueChange | (value: number) => void | – | Called with the new value after every step. |
min | number | 0 | Lowest value. |
max | number | 99 | Highest value. |
step | number | 1 | Amount added or removed per step. |
size | "sm" | "md" | "lg" | "md" | Control size. |
label | string | "Quantity" | Accessible name of the value, also used for the button labels. |
disabled | boolean | false | Disables the whole control. |
Also accepts every native <div> attribute, including ref and className.
- Press and hold either button to repeat; the rate speeds up the longer it is held.
- Each digit place rolls on its own, so going from 99 to 100 only animates the places that changed.
Accessibility
- The number is a focusable spinbutton with aria-valuenow, aria-valuemin and aria-valuemax.
- Arrow keys step, PageUp and PageDown take larger steps, and Home and End jump to the bounds.
- The −/+ buttons are labelled (e.g. "Increase quantity") and kept out of the tab order, following the WAI-ARIA spinbutton pattern.
- At a bound the button is aria-disabled rather than disabled, so focus is never lost, and the number nudges to signal the limit.
- With reduced motion enabled, digits cross-fade instead of rolling and there is no nudge.