Skip to content
Caseroom

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.

quantity.tsx
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>
  );
}
quantity.variants.ts
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';