Patterns
PatternsTier 5 · Blocks
Quantity selector
A stepper that respects stock limits.
bunx --bun shadcn@latest add @woohl/quantity-selectorPreview
Reference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/quantity-selectorregistry/tier-5-blocks/quantity-selector.tsx
"use client";
import { useId } from "react";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import { focusRing } from "@/registry/tier-2-primitives/lib/cva";
import { MinusGlyph, PlusGlyph } from "@/registry/tier-2-primitives/glyphs";
/**
* QuantitySelector.
*
* Minus, a number, plus.
*
* THE NUMBER IS AN INPUT, NOT A LABEL
* Somebody buying twelve of something should be able to type 12 rather than
* press + eleven times. It is a real `<input type="number">` with a label, so
* it works with a keyboard, with autofill and with a screen reader.
*
* The two buttons disable at the bounds instead of clamping silently — a button
* that looks pressable and does nothing is worse than one that is visibly
* unavailable. When `max` comes from stock, say so in `maxReason` so "why can't
* I add another" is answered in place.
*/
export type QuantitySelectorProps = {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
/** "Only 3 left" — shown when the max is reached. */
maxReason?: string;
/** Names the field for screen readers: "Quantity, Forest Ritual Candle". */
label?: string;
size?: "sm" | "md";
disabled?: boolean;
className?: string;
};
export function QuantitySelector({
value,
onChange,
min = 1,
max = 99,
maxReason,
label = "Quantity",
size = "md",
disabled = false,
className,
}: QuantitySelectorProps) {
const id = useId();
const atMin = value <= min;
const atMax = value >= max;
const height =
size === "sm" ? "h-[var(--control-h-sm)]" : "h-[var(--control-h-md)]";
const button = size === "sm" ? "size-[32px]" : "size-[40px]";
function clamp(next: number) {
if (Number.isNaN(next)) return;
onChange(Math.min(max, Math.max(min, Math.round(next))));
}
return (
<div data-slot="quantity-selector" className={cn("font-ui", className)}>
<label htmlFor={id} className="sr-only">
{label}
</label>
<div
className={cn(
"rounded-pill border-border-subtle bg-surface-raised inline-flex items-center border",
height,
disabled && "opacity-45",
)}
>
<button
type="button"
onClick={() => clamp(value - 1)}
disabled={disabled || atMin}
aria-label={`Decrease ${label.toLowerCase()}`}
aria-controls={id}
className={cn(
"text-text-secondary grid place-items-center rounded-full",
"motion-instant hover:not-disabled:bg-surface-sunken hover:not-disabled:text-text-primary transition-colors",
"disabled:cursor-not-allowed disabled:opacity-40",
button,
focusRing,
)}
>
<MinusGlyph size={15} />
</button>
<input
id={id}
type="number"
inputMode="numeric"
value={value}
min={min}
max={max}
disabled={disabled}
onChange={(event) => clamp(event.target.valueAsNumber)}
className={cn(
"text-body text-text-primary w-10 border-none bg-transparent text-center font-bold outline-none",
// The native spinners duplicate the two buttons either side of it.
"[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
)}
/>
<button
type="button"
onClick={() => clamp(value + 1)}
disabled={disabled || atMax}
aria-label={`Increase ${label.toLowerCase()}`}
aria-controls={id}
className={cn(
"text-text-secondary grid place-items-center rounded-full",
"motion-instant hover:not-disabled:bg-surface-sunken hover:not-disabled:text-text-primary transition-colors",
"disabled:cursor-not-allowed disabled:opacity-40",
button,
focusRing,
)}
>
<PlusGlyph size={15} />
</button>
</div>
{atMax && maxReason && (
<p className="text-caption text-warning mt-1.5" role="status">
{maxReason}
</p>
)}
</div>
);
}
QuantitySelectorProps
| Prop | Type | Default |
|---|---|---|
onChange* | (value: number) => void | — |
value* | number | — |
className | string | — |
disabled | boolean | — |
labelNames the field for screen readers: "Quantity, Forest Ritual Candle". | string | — |
max | number | — |
maxReason"Only 3 left" — shown when the max is reached. | string | — |
min | number | — |
size | "sm" | "md" | — |
Registry items
npm packages
None beyond React itself.