A rounded −/+ quantity control whose digits roll odometer-style, with min/max clamps and long-press repeat.
npx shadcn@latest add @paragon/quantity-stepperAlso installs: tooltip
"use client";
import * as React from "react";
import { Minus, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
/** Wraps a digit's distance from the rolling target into [-5, 5). */
function cellTransform(index: number, position: number) {
let distance = (((index - position) % 10) + 10) % 10;
if (distance > 5) distance -= 10;
return `translateY(${distance * 100}%)`;
}
/**
* One odometer column for a single decimal place. Ten glyphs are stacked and
* the column translates so the target digit sits in the window; the CSS
* transition retargets mid-roll instead of restarting. Direction is chosen by
* the parent so a whole-number increase rolls up and a decrease rolls down.
*/
function RollColumn({
digit,
direction,
reduced,
}: {
digit: number;
direction: 1 | -1;
reduced: boolean;
}) {
// Unwrapped position accumulates so 9→0 rolls forward through the seam.
const position = React.useRef(digit);
const [, force] = React.useReducer((n: number) => n + 1, 0);
const current = ((position.current % 10) + 10) % 10;
if (current !== digit) {
const delta =
direction > 0
? (digit - current + 10) % 10
: -((current - digit + 10) % 10);
position.current += delta;
}
React.useEffect(() => {
// Ensure the committed position renders once refs settle.
force();
}, [digit]);
return (
<span className="relative inline-block overflow-hidden" aria-hidden>
<span className="invisible">0</span>
{Array.from({ length: 10 }, (_, i) => (
<span
key={i}
className="absolute inset-0 flex justify-center"
style={{
transform: cellTransform(i, position.current),
transition: reduced
? undefined
: "transform 350ms var(--ease-in-out)",
}}
>
{i}
</span>
))}
</span>
);
}
export interface QuantityStepperProps
extends Omit<
React.ComponentProps<"div">,
"defaultValue" | "onChange" | "role"
> {
/** Controlled value. */
value?: number;
/** Initial value when uncontrolled. */
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
/** Form field name; renders a hidden input. */
name?: string;
/** Disables press-scale feedback on the +/- buttons. */
static?: boolean;
}
function clamp(v: number, min: number, max: number) {
return Math.min(max, Math.max(min, v));
}
/**
* A rounded quantity control for carts and product pages. The digits roll
* odometer-style, direction-aware; −/+ clamp to min/max and disable at the
* bounds; holding a button repeats with a little acceleration. Reduced motion
* swaps values in place.
*/
export function QuantityStepper({
value: valueProp,
defaultValue = 1,
onValueChange,
min = 1,
max = 99,
step = 1,
disabled = false,
name,
static: isStatic = false,
className,
"aria-label": ariaLabel = "Quantity",
...props
}: QuantityStepperProps) {
const [reduced, setReduced] = React.useState(false);
React.useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
const [uncontrolled, setUncontrolled] = React.useState(
clamp(defaultValue, min, max),
);
const value = valueProp ?? uncontrolled;
const valueRef = React.useRef(value);
valueRef.current = value;
const directionRef = React.useRef<1 | -1>(1);
const holdTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const setValue = React.useCallback(
(next: number) => {
const clamped = clamp(next, min, max);
if (clamped === valueRef.current) return;
directionRef.current = clamped > valueRef.current ? 1 : -1;
if (valueProp === undefined) setUncontrolled(clamped);
onValueChange?.(clamped);
},
[min, max, valueProp, onValueChange],
);
const stepBy = React.useCallback(
(dir: 1 | -1) => {
const next = clamp(valueRef.current + dir * step, min, max);
if (next === valueRef.current) {
if (holdTimer.current) {
clearTimeout(holdTimer.current);
holdTimer.current = null;
}
return;
}
setValue(next);
},
[setValue, step, min, max],
);
const stepByRef = React.useRef(stepBy);
stepByRef.current = stepBy;
const endHold = React.useCallback(() => {
if (holdTimer.current) clearTimeout(holdTimer.current);
holdTimer.current = null;
}, []);
const startHold = (dir: 1 | -1) => {
stepByRef.current(dir);
let interval = 130;
const tick = () => {
stepByRef.current(dir);
interval = Math.max(45, interval * 0.9);
holdTimer.current = setTimeout(tick, interval);
};
holdTimer.current = setTimeout(tick, 450);
};
React.useEffect(() => () => endHold(), [endHold]);
const atMin = value <= min;
const atMax = value >= max;
const digits = String(value).split("");
const buttonClass = cn(
"flex size-9 shrink-0 items-center justify-center text-muted-foreground transition-[background-color,color,scale] duration-150 ease-out select-none hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40",
!isStatic && "active:not-disabled:scale-[0.94]",
);
const holdHandlers = (dir: 1 | -1) => ({
onPointerDown: (e: React.PointerEvent) => {
if (disabled) return;
e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId);
startHold(dir);
},
onPointerUp: endHold,
onPointerCancel: endHold,
onLostPointerCapture: endHold,
});
return (
<TooltipProvider>
<div
className={cn(
"inline-flex h-9 items-stretch overflow-hidden rounded-full bg-secondary transition-[box-shadow] duration-150 focus-within:ring-2 focus-within:ring-ring/40",
disabled && "opacity-50",
className,
)}
{...props}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
tabIndex={-1}
aria-label="Decrease quantity"
disabled={disabled || atMin}
{...holdHandlers(-1)}
className={cn(buttonClass, "rounded-l-full")}
>
<Minus className="size-4" aria-hidden />
</button>
</TooltipTrigger>
<TooltipContent>Decrease</TooltipContent>
</Tooltip>
<div
role="spinbutton"
tabIndex={disabled ? -1 : 0}
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "ArrowUp") {
e.preventDefault();
stepBy(1);
} else if (e.key === "ArrowDown") {
e.preventDefault();
stepBy(-1);
} else if (e.key === "Home") {
e.preventDefault();
setValue(min);
} else if (e.key === "End") {
e.preventDefault();
setValue(max);
}
}}
className="flex min-w-9 cursor-default items-center justify-center px-1 text-sm leading-none font-medium tabular-nums select-none outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset"
>
<span className="sr-only">{value}</span>
<span className="flex leading-none">
{digits.map((d, i) => (
<RollColumn
// Re-key on digit count so columns reconcile positionally.
key={`${digits.length}-${i}`}
digit={Number(d)}
direction={directionRef.current}
reduced={reduced}
/>
))}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
tabIndex={-1}
aria-label="Increase quantity"
disabled={disabled || atMax}
{...holdHandlers(1)}
className={cn(buttonClass, "rounded-r-full")}
>
<Plus className="size-4" aria-hidden />
</button>
</TooltipTrigger>
<TooltipContent>Increase</TooltipContent>
</Tooltip>
{name && <input type="hidden" name={name} value={value} />}
</div>
</TooltipProvider>
);
}