# Quantity (Caseroom component)

Minus, a number field and plus as one control; the buttons turn off at the ends. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

0.68% of the 2,062 sites in The UI Index have a stepper (14 sites).

## 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`
- HTML: https://www.themes.dev/caseroom/r/html/quantity.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/quantity/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### quantity.astro

```astro
---
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.client.ts

```tsx
// 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 {};
```

### quantity.tsx

```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

```tsx
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';
```
