Commerce

Quantity

A compact stepper for cart lines that can be typed into and turns its minus into remove at one.

Built on
ARIA spinbuttoninputmode="numeric"ARIA live regions
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
2.9 kB
Dependencies
React

Try its options

Loading its options.

Code
import { Quantity } from "@/components/equip/quantity";

export default function Demo() {
  return <Quantity label="Quantity" defaultValue={2} max={5} maxMessage="Only 5 left" />;
}
Your versionchange its options in Look, This part

Examples

Cart line with remove

At quantity 1 the minus becomes a bin, so taking the last one off removes the line.

Linen shirt, sand, M

Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Quantity } from "@/components/equip/quantity";

export default function Example() {
  const [inCart, setInCart] = useState(true);
  if (!inCart) {
    return (
      <div style={{ display: "flex", alignItems: "center", gap: 12, fontSize: 14 }}>
        <span>Linen shirt removed.</span>
        <Button size="s" variant="outline" onClick={() => setInCart(true)}>
          Undo
        </Button>
      </div>
    );
  }
  return (
    <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, width: "100%", maxWidth: 300, fontSize: 14 }}>
      <span>Linen shirt, sand, M</span>
      <Quantity label="Quantity, Linen shirt" defaultValue={1} max={8} onRemove={() => setInCart(false)} />
    </div>
  );
}

Sizes and disabled

Code
import { Quantity } from "@/components/equip/quantity";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 16 }}>
      <Quantity label="Tickets" size="s" defaultValue={2} max={10} />
      <Quantity label="Tickets" defaultValue={2} max={10} />
      <Quantity label="Tickets" defaultValue={1} disabled />
    </div>
  );
}

Stock limit

The limit is said in a polite live region when it is reached, and the plus stays focusable so focus is not lost.

Code
import { Quantity } from "@/components/equip/quantity";

export default function Example() {
  return <Quantity label="Quantity, Ceramic mug" defaultValue={2} max={3} maxMessage="Only 3 left in stock" />;
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the group and the spinbutton, such as "Quantity, Linen shirt".
valuenumbernoneThe quantity, when controlled.
defaultValuenumberminThe first quantity, when uncontrolled.
onValueChange(value: number) => voidnoneCalled with each new clamped quantity.
minnumber1Lowest quantity.
maxnumbernoneHighest quantity, usually the stock left.
onRemove() => voidnoneWhen given, minus becomes a remove button at the minimum and calls this.
maxMessagestring"Maximum is {max}"Shown and announced when the quantity reaches max, such as "Only 5 left".
size"s" | "m""m"Control height.
disabledbooleanfalseDisables every control in the stepper.
...restComponentProps<"div">noneEvery native div attribute except role, defaultValue and onChange, which the part owns.

Anatomy

root.eq-quantityA group named by label.
control.eq-quantity-controlThe bordered row of minus, number and plus.
button.eq-quantity-buttondata-action is decrease, increase or remove.
input.eq-quantity-inputA text input with role="spinbutton" that rolls on change.
message.eq-quantity-messageA polite live region, empty until max is reached.

Keyboard

ArrowUpAdds one, up to max.
ArrowDownTakes one away, down to min. It never removes the line.
HomeSets the minimum.
EndSets the maximum, when there is one.
0-9Types a quantity; it is clamped when you press Enter or leave the field.
EscapeDiscards a typed quantity.
EnterSpaceOn a button, steps or removes.

Notes

  • The minus and plus stay focusable at a bound and carry aria-disabled, so focus is not lost at the moment the limit is announced.
  • The remove button replaces minus at the minimum; after onRemove the line usually unmounts, so move focus to something sensible, such as the next line or an undo button.
  • The bin ignores the second click of a double-click and held-key repeats, so stepping down from 2 never removes the line by accident.
  • The number is a text input with inputmode="numeric", which brings up a number pad on phones without the quirks of type="number".
  • The limit message is visible as well as announced, so sighted users also learn why plus stopped.
  • The value rolls a few pixels in the direction of the change; with reduced motion it simply changes.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/quantity.tsx173 lines
"use client";

import { useId, useState, type ComponentProps, type KeyboardEvent } from "react";
import "./quantity.css";

export type QuantityProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange" | "children" | "role"> & {
  /** Names the spinbutton, such as "Quantity, Linen shirt". */
  label: string;
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  min?: number;
  max?: number;
  /** When given, the minus button becomes a remove button at the minimum. */
  onRemove?: () => void;
  /** Said and shown when the value reaches max, such as "Only 5 left". */
  maxMessage?: string;
  size?: "s" | "m";
  disabled?: boolean;
};

const iconProps = {
  viewBox: "0 0 16 16",
  fill: "none",
  stroke: "currentColor",
  strokeWidth: 1.5,
  strokeLinecap: "round",
  strokeLinejoin: "round",
  "aria-hidden": true,
} as const;

export function Quantity({
  label,
  value,
  defaultValue,
  onValueChange,
  min = 1,
  max,
  onRemove,
  maxMessage,
  size = "m",
  disabled = false,
  className,
  ...rest
}: QuantityProps) {
  const messageId = useId();
  const [inner, setInner] = useState(defaultValue ?? min);
  const [draft, setDraft] = useState<string | null>(null);
  /* Each change flips the parity so the roll animation restarts without remounting the input. */
  const [roll, setRoll] = useState<{ n: number; dir: "up" | "down" } | null>(null);
  const current = value ?? inner;
  const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min, n));
  const atMin = current <= min;
  const atMax = max !== undefined && current >= max;
  const removes = atMin && onRemove !== undefined;

  const set = (next: number) => {
    const n = clamp(next);
    setDraft(null);
    if (n === current) return;
    setRoll((r) => ({ n: (r?.n ?? 0) + 1, dir: n > current ? "up" : "down" }));
    if (value === undefined) setInner(n);
    onValueChange?.(n);
  };

  const commit = () => {
    if (draft === null) return;
    const n = Number.parseInt(draft, 10);
    if (Number.isNaN(n)) setDraft(null);
    else set(n);
  };

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    const editing = draft !== null;
    const keys: Record<string, (() => void) | false> = {
      ArrowUp: () => set(current + 1),
      ArrowDown: () => set(current - 1),
      Home: () => set(min),
      End: max !== undefined && (() => set(max)),
      /* Only while typing, so Escape still closes a surrounding dialog and Enter still submits. */
      Enter: editing && commit,
      Escape: editing && (() => setDraft(null)),
    };
    const run = keys[event.key];
    if (!run) return;
    event.preventDefault();
    run();
  };

  return (
    <div
      {...rest}
      className={["eq-quantity", className].filter(Boolean).join(" ")}
      role="group"
      aria-label={label}
      data-size={size}
      data-disabled={disabled || undefined}
    >
      <div className="eq-quantity-control">
        {removes ? (
          <button
            type="button"
            className="eq-quantity-button"
            data-action="remove"
            aria-label="Remove"
            disabled={disabled}
            onClick={(event) => {
              /* The bin takes minus's place, so ignore the second click of a double-click. */
              if (event.detail < 2) onRemove?.();
            }}
            onKeyDown={(event) => {
              if (event.repeat) event.preventDefault();
            }}
          >
            <svg {...iconProps}>
              <path d="M2.75 4.25h10.5M6.25 4.25v-1.5h3.5v1.5M4.25 4.25l.6 8.4a1 1 0 0 0 1 .85h4.3a1 1 0 0 0 1-.85l.6-8.4M6.75 7v4M9.25 7v4" />
            </svg>
          </button>
        ) : (
          <button
            type="button"
            className="eq-quantity-button"
            data-action="decrease"
            aria-label="Decrease"
            aria-disabled={atMin || undefined}
            disabled={disabled}
            onClick={() => set(current - 1)}
          >
            <svg {...iconProps}>
              <path d="M3.75 8h8.5" />
            </svg>
          </button>
        )}
        <input
          className="eq-quantity-input"
          type="text"
          inputMode="numeric"
          role="spinbutton"
          aria-label={label}
          aria-valuenow={current}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-describedby={messageId}
          autoComplete="off"
          disabled={disabled}
          value={draft ?? String(current)}
          data-roll={roll ? (roll.n % 2 ? "a" : "b") : undefined}
          data-dir={roll?.dir}
          onChange={(event) => setDraft(event.target.value.replace(/\D/g, ""))}
          onBlur={commit}
          onKeyDown={onKeyDown}
          onFocus={(event) => event.target.select()}
        />
        <button
          type="button"
          className="eq-quantity-button"
          data-action="increase"
          aria-label="Increase"
          aria-disabled={atMax || undefined}
          disabled={disabled}
          onClick={() => set(current + 1)}
        >
          <svg {...iconProps}>
            <path d="M3.75 8h8.5M8 3.75v8.5" />
          </svg>
        </button>
      </div>
      <p className="eq-quantity-message" id={messageId} aria-live="polite">
        {atMax ? (maxMessage ?? `Maximum is ${max}`) : ""}
      </p>
    </div>
  );
}
components/equip/quantity.css147 lines
@layer equip {
  .eq-quantity {
    --_h: var(--eq-h-m);
    --_pad: 2px;

    display: inline-grid;
    justify-items: start;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.3 var(--eq-font);
  }

  .eq-quantity[data-size="s"] {
    --_h: var(--eq-h-s);
    --_pad: 1px;
    font-size: var(--eq-text-s);
  }

  .eq-quantity[data-disabled] {
    opacity: 0.45;
  }

  .eq-quantity-control {
    display: inline-flex;
    align-items: center;
    height: var(--_h);
    padding: var(--_pad);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
  }

  .eq-quantity-button {
    display: grid;
    place-items: center;
    flex: none;
    width: calc(var(--_h) - 2px - 2 * var(--_pad));
    height: calc(var(--_h) - 2px - 2 * var(--_pad));
    padding: 0;
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink-2);
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-quantity-button svg {
    width: 16px;
    height: 16px;
  }

  @media (hover: hover) {
    .eq-quantity-button:hover:not(:disabled, [aria-disabled="true"]) {
      background: var(--eq-tint);
      color: var(--eq-ink);
    }

    .eq-quantity-button[data-action="remove"]:hover:not(:disabled) {
      color: var(--eq-danger);
    }
  }

  .eq-quantity-button:active:not(:disabled, [aria-disabled="true"]) {
    scale: var(--eq-press);
  }

  .eq-quantity-button:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  /* Stays focusable at a bound so focus is not dropped while the reason is announced. */
  .eq-quantity-button[aria-disabled="true"] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-quantity-button:disabled,
  .eq-quantity-input:disabled {
    cursor: not-allowed;
  }

  .eq-quantity-input {
    --_from: 0;

    width: calc(3ch + var(--eq-space-2));
    height: 100%;
    padding: 0;
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: inherit;
    font: inherit;
    font-weight: var(--eq-weight-strong);
    font-variant-numeric: tabular-nums;
    text-align: center;
  }

  .eq-quantity-input:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -1px;
  }

  .eq-quantity-input[data-dir="up"] {
    --_from: 0.45em;
  }

  .eq-quantity-input[data-dir="down"] {
    --_from: -0.45em;
  }

  /* Two identical keyframes, alternated, so every change restarts the roll. */
  .eq-quantity-input[data-roll="a"] {
    animation: eq-quantity-roll-a var(--eq-dur-move) var(--eq-spring);
  }

  .eq-quantity-input[data-roll="b"] {
    animation: eq-quantity-roll-b var(--eq-dur-move) var(--eq-spring);
  }

  @keyframes eq-quantity-roll-a {
    from {
      translate: 0 var(--_from);
      opacity: 0.3;
    }
  }

  @keyframes eq-quantity-roll-b {
    from {
      translate: 0 var(--_from);
      opacity: 0.3;
    }
  }

  .eq-quantity-message {
    margin: 0;
    color: var(--eq-ink-2);
    font-size: var(--eq-text-xs);
  }

  .eq-quantity-message:not(:empty) {
    margin-top: var(--eq-space-1);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index