Components · Forms
Quantity
Minus, a number field and plus as one control; the buttons turn off at the ends.
0.68% of the 2,062 sites in The UI Index have a stepper (14 sites).
Both previews render the same markup; the HTML tab below is that markup without a framework.
Install
- React
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ quantity.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ astro/ quantity.json
Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { MINUS_PATH, PLUS_PATH, quantity as c } from './quantity.variants';
export interface QuantityProps {
id: string;
label: string;
defaultValue: number;
min?: number;
max?: number;
step?: number;
name?: string;
onValueChange?: (value: number) => void;
className?: string;
}
/** Minus, a number field and plus as one control; the buttons turn off at min and max. */
export function Quantity({ id, label, defaultValue, min = 0, max, step = 1, name, onValueChange, className }: QuantityProps) {
const [v, setV] = useState(defaultValue);
const set = (n: number) => {
const clamped = Math.max(min, max === undefined ? n : Math.min(max, n));
setV(clamped);
onValueChange?.(clamped);
};
return (
<div className={cn(c.root, className)}>
<label htmlFor={id} className={c.label}>{label}</label>
<div data-quantity="" className={c.group}>
<button type="button" data-step="-1" aria-label={`Fewer: ${label}`} aria-controls={id} disabled={v <= min} className={c.button} onClick={(e) => { e.preventDefault(); set(v - step); }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true"><path d={MINUS_PATH} /></svg>
</button>
<input type="number" id={id} name={name} value={v} min={min} max={max} step={step} inputMode="numeric" className={c.input} onChange={(e) => setV(Number(e.target.value))} onBlur={(e) => set(Number(e.target.value))} />
<button type="button" data-step="1" aria-label={`More: ${label}`} aria-controls={id} disabled={max !== undefined && v >= max} className={c.button} onClick={(e) => { e.preventDefault(); set(v + step); }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true"><path d={PLUS_PATH} /></svg>
</button>
</div>
</div>
);
}import { fieldClasses } from '../input/input.variants';
/** A quantity stepper (ported from Flightdeck's number-stepper): minus, a number field, plus, joined into one
* control. The field takes typing and the arrow keys; the buttons step by `step` and turn off at min and max. */
export const quantity = {
root: 'grid gap-2',
label: 'text-sm font-medium',
group: 'inline-flex w-fit items-stretch',
button:
'relative inline-flex size-9 shrink-0 cursor-pointer items-center focus-visible:z-10 justify-center border border-input bg-background text-foreground transition-[background-color,transform] hover:bg-accent active:bg-pressed disabled:pointer-events-none disabled:opacity-50 first:rounded-l-md last:rounded-r-md [&_svg]:size-3.5',
input: `${fieldClasses} relative h-9 w-14 focus-visible:z-10 rounded-none border-x-0 text-center tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none`,
};
export const MINUS_PATH = 'M3.5 8h9';
export const PLUS_PATH = 'M8 3.5v9M3.5 8h9';---
import { MINUS_PATH, PLUS_PATH, quantity as c } from './quantity.variants';
interface Props {
id: string;
label: string;
value: number;
min?: number;
max?: number;
step?: number;
name?: string;
class?: string;
}
const { id, label, value, min = 0, max, step = 1, name, class: className } = Astro.props;
---
<div class:list={[c.root, className]}>
<label for={id} class={c.label}>{label}</label>
<div data-quantity class={c.group}>
<button type="button" data-step="-1" aria-label={`Fewer: ${label}`} aria-controls={id} disabled={value <= min} class={c.button}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d={MINUS_PATH} /></svg>
</button>
<input type="number" id={id} name={name} value={value} min={min} max={max} step={step} inputmode="numeric" class={c.input} />
<button type="button" data-step="1" aria-label={`More: ${label}`} aria-controls={id} disabled={max !== undefined && value >= max} class={c.button}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d={PLUS_PATH} /></svg>
</button>
</div>
</div>
<script>
import './quantity.client';
</script>// Quantity stepper for Astro and plain HTML (React keeps state): − and + step the field within min/max and turn
// off at the ends; typing is clamped on change; every change fires the field's input and change events.
declare global {
interface Window {
__caseroomQuantity?: boolean;
}
}
const sync = (input: HTMLInputElement) => {
const v = Number(input.value);
const group = input.closest('[data-quantity]')!;
group.querySelector<HTMLButtonElement>('[data-step="-1"]')!.disabled = input.min !== '' && v <= Number(input.min);
group.querySelector<HTMLButtonElement>('[data-step="1"]')!.disabled = input.max !== '' && v >= Number(input.max);
};
if (typeof window !== 'undefined' && !window.__caseroomQuantity) {
window.__caseroomQuantity = true;
document.addEventListener('click', (e) => {
const b = (e.target as Element).closest<HTMLButtonElement>('[data-quantity] [data-step]');
if (!b || e.defaultPrevented) return;
const input = b.closest('[data-quantity]')!.querySelector<HTMLInputElement>('input')!;
if (b.dataset.step === '1') input.stepUp();
else input.stepDown();
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
sync(input);
});
document.addEventListener('change', (e) => {
const input = e.target as HTMLInputElement;
if (!input.matches?.('[data-quantity] input')) return;
const v = Number(input.value);
if (input.min !== '' && v < Number(input.min)) input.value = input.min;
if (input.max !== '' && v > Number(input.max)) input.value = input.max;
sync(input);
});
}
export {};import { fieldClasses } from '../input/input.variants';
/** A quantity stepper (ported from Flightdeck's number-stepper): minus, a number field, plus, joined into one
* control. The field takes typing and the arrow keys; the buttons step by `step` and turn off at min and max. */
export const quantity = {
root: 'grid gap-2',
label: 'text-sm font-medium',
group: 'inline-flex w-fit items-stretch',
button:
'relative inline-flex size-9 shrink-0 cursor-pointer items-center focus-visible:z-10 justify-center border border-input bg-background text-foreground transition-[background-color,transform] hover:bg-accent active:bg-pressed disabled:pointer-events-none disabled:opacity-50 first:rounded-l-md last:rounded-r-md [&_svg]:size-3.5',
input: `${fieldClasses} relative h-9 w-14 focus-visible:z-10 rounded-none border-x-0 text-center tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none`,
};
export const MINUS_PATH = 'M3.5 8h9';
export const PLUS_PATH = 'M8 3.5v9M3.5 8h9';<div class="flex flex-wrap justify-center gap-8">
<div class="grid gap-2">
<label for="ex-seats-q" class="text-sm font-medium">Seats</label>
<div data-quantity class="inline-flex w-fit items-stretch">
<button type="button" data-step="-1" aria-label="Fewer: Seats" aria-controls="ex-seats-q" class="relative inline-flex size-9 shrink-0 cursor-pointer items-center focus-visible:z-10 justify-center border border-input bg-background text-foreground transition-[background-color,transform] hover:bg-accent active:bg-pressed disabled:pointer-events-none disabled:opacity-50 first:rounded-l-md last:rounded-r-md [&_svg]:size-3.5">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M3.5 8h9"></path></svg>
</button>
<input type="number" id="ex-seats-q" value="3" min="1" max="20" step="1" inputmode="numeric" class="w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm text-foreground transition-colors placeholder:text-muted-foreground enabled:hover:border-foreground/85 enabled:active:bg-muted aria-invalid:enabled:hover:border-destructive aria-invalid:enabled:hover:bg-[color-mix(in_oklab,var(--destructive)_6%,var(--background))] aria-invalid:enabled:active:bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:outline-destructive relative h-9 w-14 focus-visible:z-10 rounded-none border-x-0 text-center tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none">
<button type="button" data-step="1" aria-label="More: Seats" aria-controls="ex-seats-q" class="relative inline-flex size-9 shrink-0 cursor-pointer items-center focus-visible:z-10 justify-center border border-input bg-background text-foreground transition-[background-color,transform] hover:bg-accent active:bg-pressed disabled:pointer-events-none disabled:opacity-50 first:rounded-l-md last:rounded-r-md [&_svg]:size-3.5">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M8 3.5v9M3.5 8h9"></path></svg>
</button>
</div>
</div>
<div class="grid gap-2">
<label for="ex-domains-q" class="text-sm font-medium">Extra domains</label>
<div data-quantity class="inline-flex w-fit items-stretch">
<button type="button" data-step="-1" aria-label="Fewer: Extra domains" aria-controls="ex-domains-q" disabled class="relative inline-flex size-9 shrink-0 cursor-pointer items-center focus-visible:z-10 justify-center border border-input bg-background text-foreground transition-[background-color,transform] hover:bg-accent active:bg-pressed disabled:pointer-events-none disabled:opacity-50 first:rounded-l-md last:rounded-r-md [&_svg]:size-3.5">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M3.5 8h9"></path></svg>
</button>
<input type="number" id="ex-domains-q" value="0" min="0" max="5" step="1" inputmode="numeric" class="w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm text-foreground transition-colors placeholder:text-muted-foreground enabled:hover:border-foreground/85 enabled:active:bg-muted aria-invalid:enabled:hover:border-destructive aria-invalid:enabled:hover:bg-[color-mix(in_oklab,var(--destructive)_6%,var(--background))] aria-invalid:enabled:active:bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:outline-destructive relative h-9 w-14 focus-visible:z-10 rounded-none border-x-0 text-center tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none">
<button type="button" data-step="1" aria-label="More: Extra domains" aria-controls="ex-domains-q" class="relative inline-flex size-9 shrink-0 cursor-pointer items-center focus-visible:z-10 justify-center border border-input bg-background text-foreground transition-[background-color,transform] hover:bg-accent active:bg-pressed disabled:pointer-events-none disabled:opacity-50 first:rounded-l-md last:rounded-r-md [&_svg]:size-3.5">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M8 3.5v9M3.5 8h9"></path></svg>
</button>
</div>
</div>
</div><script type="module" src="/caseroom/r/html/quantity.js"></script>