Skip to content
    Style: Wireframe

    Components · Forms

    Quantity

    Minus, a number field and plus as one control; the buttons turn off at the ends.

    0.68% of the 2,073 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/ui/r/quantity.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/quantity.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI 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';