QuantityStepper

A − value + control for choosing how many of something: a cart line, a menu item, a ticket count. The value can also be typed.

Commerce QuantityStepper.jsx QuantityStepper.d.ts QuantityStepper.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

QuantityStepper is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { QuantityStepper } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

A − value + control for choosing how many of something: a cart line, a menu item, a ticket count. The value can also be typed.

Use it when

  • The quantity is small and usually changes by one: cart lines, menu items, seats, tickets.
  • A cart line should be removable from the same control: pass onRemove, and at the minimum the minus button becomes a remove button.

Don't use it when

  • The number is large or rarely nudged (an amount of money, a year). Use Input with type="number" or inputMode="numeric".
  • The value is approximate. Use Slider.
  • There are a few named options (Small, Medium, Large). Use RadioGroup or ButtonGroup.

Example

<QuantityStepper label="Quantity" value={qty} onChange={setQty} max={10} />

{/* Cart line: at 1 the minus becomes a remove button */}
<QuantityStepper
  label="Quantity, oat latte"
  removeLabel="Remove oat latte"
  value={line.qty}
  onChange={(qty) => update(line.id, qty)}
  onRemove={() => remove(line.id)}
  size="sm"
/>

Variants

PropWhat it is for
size="md"Default, 40px: product pages and menus, touch-first.
size="sm"32px: cart lines and dense lists.
min / max / stepLimits and increment. min defaults to 1 and step to 1; with no max there is no upper limit. Typed values are snapped to the step grid counted from min, then clamped.
onRemoveAt min, the minus button shows a trash icon, is named removeLabel ("Remove") and calls onRemove instead of stepping.
disabledBoth buttons and the field are disabled.

The stepper is controlled: it shows value and calls onChange(next) with a value already clamped and snapped. It never calls onChange with the current value.

Composition

Inline. It sits at the end of a cart line or menu row, beside a Price, or under a product's options on a product page. For a visible label, put a <label htmlFor> pointing at id (the value field's id) or use it inside a Field with htmlFor.

Tokens

No Tier 3 tokens of its own: it is a field, so it reads the input tokens and the ghost button tokens, and restyling those restyles it with the rest of the form.

  • Frame: --dt-input-height-sm|md, --dt-input-radius, --dt-input-border-width, --dt-input-border, --dt-input-bg, and --dt-input-border-disabled / --dt-input-bg-disabled when disabled.
  • Value: --dt-input-fg (--dt-input-fg-disabled), type from --dt-text-label-md-* (sm) or --dt-text-label-lg-* (md), tabular figures.
  • Buttons: --dt-button-ghost-bg, --dt-button-ghost-bg-hover, --dt-button-ghost-fg, --dt-button-transition; --dt-text-disabled at a limit. Icons read --dt-size-icon-sm|md.

Accessibility

  • The value is an <input role="spinbutton"> named by label, with aria-valuenow, aria-valuemin and (when max is set) aria-valuemax, and inputMode="numeric" for a number keypad on phones. The whole control is a role="group" with the same name.
  • Keyboard, in the field: ArrowUp and ArrowDown add or take away one step; PageUp and PageDown ten steps; Home jumps to min and End to max (when set). Typing a number commits on Enter or on blur, clamped and snapped; Escape abandons what was typed. ArrowDown at the minimum never removes the line; removing takes the button.
  • Buttons: named "Decrease quantity" and "Increase quantity" by default (decreaseLabel, increaseLabel). In a cart with several lines, pass names that include the item ("Increase oat latte") so a screen-reader user moving between buttons knows which line they are on. At a limit a button is aria-disabled rather than disabled, so the focus stays on it when a click reaches the limit instead of dropping to the page.
  • The remove button is named removeLabel, "Remove" by default; pass the item name here too.
  • label is required in the types. There is no visible label; the context (a cart line's title) is the visual one.

Content

  • label is sentence case and names the thing counted: "Quantity", "Tickets", "Quantity, oat latte".
  • Button labels are verb-first: "Increase quantity", "Remove oat latte".

Props

The typed contract, from QuantityStepper.d.ts.

import * as React from "react";

/** A − value + control for a quantity, for cart lines and menus. */
export interface QuantityStepperProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue" | "children"> {
  /** The current quantity. The stepper is controlled: it shows this value and reports changes. */
  value: number;
  /** Called with the next quantity, already clamped to min and max and snapped to step. */
  onChange: (next: number) => void;
  /** The accessible name of the group and the value field, e.g. "Quantity" or "Quantity, oat latte". */
  label: string;
  /** Lowest value. The minus button stops here, or becomes a remove button when onRemove is given. @default 1 */
  min?: number;
  /** Highest value. No upper limit when omitted. */
  max?: number;
  /** Amount each press or arrow key adds or takes away. PageUp and PageDown move ten steps. @default 1 */
  step?: number;
  /** @default "md" */
  size?: "sm" | "md";
  /** Disables both buttons and the field. */
  disabled?: boolean;
  /** When given, the minus button becomes a remove button (trash icon) at min, and pressing it calls this. */
  onRemove?: () => void;
  /** Accessible name of the minus button. @default "Decrease quantity" */
  decreaseLabel?: string;
  /** Accessible name of the plus button. @default "Increase quantity" */
  increaseLabel?: string;
  /** Accessible name of the remove button, e.g. "Remove oat latte". @default "Remove" */
  removeLabel?: string;
  /** id of the value field, for an external label's htmlFor. Generated when omitted. */
  id?: string;
}

export declare function QuantityStepper(props: QuantityStepperProps): React.JSX.Element;

Tokens it reads

Every custom property QuantityStepper.jsx resolves, read from the source: 14 component, 152 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-button-ghost-bgcomponentvar(--dt-surface-action-ghost)
--dt-button-ghost-bg-hovercomponentvar(--dt-surface-action-ghost-hover)
--dt-button-ghost-fgcomponentvar(--dt-text-on-action-ghost)
--dt-button-transitioncomponentvar(--dt-motion-micro)
--dt-input-bgcomponentvar(--dt-surface-base)
--dt-input-bg-disabledcomponentvar(--dt-surface-disabled)
--dt-input-bordercomponentvar(--dt-border-default)
--dt-input-border-disabledcomponentvar(--dt-border-disabled)
--dt-input-border-widthcomponentvar(--dt-border-width-default)
--dt-input-fgcomponentvar(--dt-text-primary)
--dt-input-fg-disabledcomponentvar(--dt-text-disabled)
--dt-input-height-mdcomponentvar(--dt-size-control-md)
--dt-input-height-smcomponentvar(--dt-size-control-sm)
--dt-input-radiuscomponentvar(--dt-radius-control)
--dt-size-icon-mdsemanticvar(--dt-dim-5)
--dt-size-icon-smsemanticvar(--dt-dim-4)
--dt-text-artboard-body-linesemanticcalc(var(--dt-line-height-md) * 2.35)
--dt-text-artboard-body-sizesemanticcalc(var(--dt-font-size-md) * 2.5)
--dt-text-artboard-display-familysemanticvar(--dt-text-display-lg-family)
--dt-text-artboard-display-linesemanticcalc(var(--dt-line-height-7xl) * 2.55)
--dt-text-artboard-display-sizesemanticcalc(var(--dt-font-size-7xl) * 2.8)
--dt-text-artboard-display-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-artboard-display-weightsemanticvar(--dt-text-display-lg-weight)
--dt-text-artboard-meta-sizesemanticcalc(var(--dt-font-size-sm) * 2.2)
--dt-text-artboard-meta-trackingsemanticvar(--dt-tracking-wider)
--dt-text-artboard-title-linesemanticcalc(var(--dt-line-height-7xl) * 1.7)
--dt-text-artboard-title-sizesemanticcalc(var(--dt-font-size-7xl) * 1.75)
--dt-text-body-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-body-lg-linesemanticvar(--dt-line-height-lg)
--dt-text-body-lg-sizesemanticvar(--dt-font-size-lg)
--dt-text-body-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-lg-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-md-familysemanticvar(--dt-font-family-sans)
--dt-text-body-md-linesemanticvar(--dt-line-height-md)
--dt-text-body-md-sizesemanticvar(--dt-font-size-md)
--dt-text-body-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-body-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-body-xs-linesemanticvar(--dt-line-height-xs)
--dt-text-body-xs-sizesemanticvar(--dt-font-size-xs)
--dt-text-body-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-xs-weightsemanticvar(--dt-font-weight-regular)
--dt-text-brandsemanticvar(--dt-color-primary-700)
--dt-text-brand-secondarysemanticvar(--dt-color-secondary-700)
--dt-text-code-md-familysemanticvar(--dt-font-family-mono)
--dt-text-code-md-linesemanticvar(--dt-line-height-sm)
--dt-text-code-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-code-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-code-sm-familysemanticvar(--dt-font-family-mono)
--dt-text-code-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-code-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-code-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-dangersemanticvar(--dt-color-red-800)
--dt-text-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-display-2xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-2xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-2xl-sizesemanticvar(--dt-font-size-fluid-2xl)
--dt-text-display-2xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-2xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-display-lg-linesemanticvar(--dt-line-height-7xl)
--dt-text-display-lg-sizesemanticvar(--dt-font-size-7xl)
--dt-text-display-lg-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-md-familysemanticvar(--dt-font-family-sans)
--dt-text-display-md-linesemanticvar(--dt-line-height-6xl)
--dt-text-display-md-sizesemanticvar(--dt-font-size-6xl)
--dt-text-display-md-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-display-sm-linesemanticvar(--dt-line-height-5xl)
--dt-text-display-sm-sizesemanticvar(--dt-font-size-5xl)
--dt-text-display-sm-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-display-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-xl-sizesemanticvar(--dt-font-size-fluid-xl)
--dt-text-display-xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-eyebrow-familysemanticvar(--dt-font-family-secondary)
--dt-text-eyebrow-linesemanticvar(--dt-line-height-2xs)
--dt-text-eyebrow-sizesemanticvar(--dt-font-size-2xs)
--dt-text-eyebrow-trackingsemanticvar(--dt-tracking-wider)
--dt-text-eyebrow-weightsemanticvar(--dt-font-weight-semibold)
--dt-text-heading-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-lg-linesemanticvar(--dt-line-height-3xl)
--dt-text-heading-lg-sizesemanticvar(--dt-font-size-3xl)
--dt-text-heading-lg-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-md-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-md-linesemanticvar(--dt-line-height-2xl)
--dt-text-heading-md-sizesemanticvar(--dt-font-size-2xl)
--dt-text-heading-md-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-sm-linesemanticvar(--dt-line-height-xl)
--dt-text-heading-sm-sizesemanticvar(--dt-font-size-xl)
--dt-text-heading-sm-trackingsemanticvar(--dt-tracking-snug)
--dt-text-heading-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xl-linesemanticvar(--dt-line-height-4xl)
--dt-text-heading-xl-sizesemanticvar(--dt-font-size-4xl)
--dt-text-heading-xl-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-heading-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xs-linesemanticvar(--dt-line-height-lg)
--dt-text-heading-xs-sizesemanticvar(--dt-font-size-lg)
--dt-text-heading-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-heading-xs-weightsemanticvar(--dt-font-weight-medium)
--dt-text-headlinesemanticvar(--dt-text-primary)
--dt-text-infosemanticvar(--dt-color-cyan-900)
--dt-text-inversesemanticvar(--dt-color-neutral-050)
--dt-text-label-lg-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-lg-linesemanticvar(--dt-line-height-md)
--dt-text-label-lg-sizesemanticvar(--dt-font-size-md)
--dt-text-label-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-linesemanticvar(--dt-line-height-sm)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-label-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-sm-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-label-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-on-actionsemanticvar(--dt-color-white)
--dt-text-on-action-brandsemanticvar(--dt-color-white)
--dt-text-on-action-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-action-dangersemanticvar(--dt-color-white)
--dt-text-on-action-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-on-action-ghostsemanticvar(--dt-color-neutral-900)
--dt-text-on-action-secondarysemanticvar(--dt-color-neutral-900)
--dt-text-on-brandsemanticvar(--dt-color-white)
--dt-text-on-brand-mutedsemanticvar(--dt-color-primary-900)
--dt-text-on-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-brand-secondary-mutedsemanticvar(--dt-color-secondary-900)
--dt-text-on-dangersemanticvar(--dt-color-white)
--dt-text-on-infosemanticvar(--dt-color-white)
--dt-text-on-scrimsemanticvar(--dt-color-neutral-050)
--dt-text-on-scrim-brandsemanticvar(--dt-color-primary-200)
--dt-text-on-scrim-secondarysemanticvar(--dt-color-neutral-200)
--dt-text-on-scrim-strongsemanticvar(--dt-color-white)
--dt-text-on-selectedsemanticvar(--dt-color-neutral-900)
--dt-text-on-selected-brandsemanticvar(--dt-color-primary-900)
--dt-text-on-successsemanticvar(--dt-color-white)
--dt-text-on-warningsemanticvar(--dt-color-neutral-950)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-successsemanticvar(--dt-color-green-800)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-text-warningsemanticvar(--dt-color-amber-900)
--dt-text-wordmarksemanticvar(--dt-text-primary)

Source

React only, no dependencies, every value a token.

import React from "react";

const ICONS = {
  minus: ["M5 12h14"],
  plus: ["M5 12h14", "M12 5v14"],
  trash: ["M3 6h18", "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6", "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2", "M10 11v6", "M14 11v6"],
};

function Icon({ name, size }) {
  return (
    <svg
      viewBox="0 0 24 24" aria-hidden="true" focusable="false"
      fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
      style={{ display: "block", flex: "none", width: size, height: size }}
    >
      {ICONS[name].map((d) => <path key={d} d={d} />)}
    </svg>
  );
}

const SIZES = {
  sm: { height: "var(--dt-input-height-sm)", icon: "var(--dt-size-icon-sm)", text: "label-md" },
  md: { height: "var(--dt-input-height-md)", icon: "var(--dt-size-icon-md)", text: "label-lg" },
};

export function QuantityStepper({
  value,
  onChange,
  label,
  min = 1,
  max,
  step = 1,
  size = "md",
  disabled = false,
  onRemove,
  decreaseLabel = "Decrease quantity",
  increaseLabel = "Increase quantity",
  removeLabel = "Remove",
  id,
  style,
  ...rest
}) {
  const s = SIZES[size] || SIZES.md;
  const auto = React.useId();
  const inputId = id || auto;
  const [draft, setDraft] = React.useState(null);
  const [hover, setHover] = React.useState(null);

  const top = typeof max === "number" ? max : Infinity;
  /* Onto the step grid counted from min, then inside the limits. */
  const clamp = (n) => {
    const snapped = min + Math.round((n - min) / step) * step;
    return Math.min(Math.max(snapped, min), top);
  };
  const commit = (n) => {
    setDraft(null);
    const next = clamp(n);
    if (next !== value) onChange(next);
  };
  /* What is typed counts once it is committed; until then a step starts from
     it, so typing 4 and pressing ArrowUp gives 5. */
  const current = () => {
    if (draft == null) return value;
    const typed = Number(draft.replace(/[^\d.-]/g, ""));
    return draft.trim() === "" || Number.isNaN(typed) ? value : typed;
  };

  const atMin = value <= min;
  const atMax = value >= top;
  const removing = !!onRemove && atMin;
  const decOff = disabled || (atMin && !removing);
  const incOff = disabled || atMax;

  const onKeyDown = (e) => {
    if (disabled) return;
    const big = step * 10;
    let next = null;
    if (e.key === "ArrowUp") next = current() + step;
    else if (e.key === "ArrowDown") next = current() - step;
    else if (e.key === "PageUp") next = current() + big;
    else if (e.key === "PageDown") next = current() - big;
    else if (e.key === "Home") next = min;
    else if (e.key === "End" && top !== Infinity) next = top;
    else if (e.key === "Enter") next = current();
    else if (e.key === "Escape" && draft != null) { e.preventDefault(); setDraft(null); return; }
    if (next == null) return;
    e.preventDefault();
    commit(next);
  };

  /* The limit buttons stay focusable (aria-disabled, not disabled), so focus
     is not dropped to the page when a click reaches the limit. */
  const button = (kind, off, onPress, ariaLabel, icon) => (
    <button
      type="button"
      aria-label={ariaLabel}
      title={ariaLabel}
      aria-disabled={off && !disabled ? true : undefined}
      aria-controls={inputId}
      disabled={disabled}
      onClick={() => { if (!off) onPress(); }}
      onMouseEnter={() => setHover(kind)}
      onMouseLeave={() => setHover(null)}
      style={{
        display: "inline-flex", alignItems: "center", justifyContent: "center", flex: "none",
        width: s.height, height: "100%", padding: 0, border: 0,
        borderRadius: "var(--dt-input-radius)",
        background: hover === kind && !off ? "var(--dt-button-ghost-bg-hover)" : "var(--dt-button-ghost-bg)",
        color: off ? "var(--dt-text-disabled)" : "var(--dt-button-ghost-fg)",
        cursor: off ? "not-allowed" : "pointer",
        transition: "background var(--dt-button-transition), color var(--dt-button-transition)",
      }}
    >
      <Icon name={icon} size={s.icon} />
    </button>
  );

  return (
    <div
      role="group"
      aria-label={label}
      style={{
        display: "inline-flex", alignItems: "stretch", boxSizing: "border-box",
        height: s.height,
        border: `var(--dt-input-border-width) solid ${disabled ? "var(--dt-input-border-disabled)" : "var(--dt-input-border)"}`,
        borderRadius: "var(--dt-input-radius)",
        background: disabled ? "var(--dt-input-bg-disabled)" : "var(--dt-input-bg)",
        ...style,
      }}
      {...rest}
    >
      {removing
        ? button("dec", decOff, onRemove, removeLabel, "trash")
        : button("dec", decOff, () => commit(value - step), decreaseLabel, "minus")}
      <input
        id={inputId}
        type="text"
        role="spinbutton"
        inputMode="numeric"
        autoComplete="off"
        aria-label={label}
        aria-valuenow={value}
        aria-valuemin={min}
        aria-valuemax={top === Infinity ? undefined : top}
        disabled={disabled}
        value={draft != null ? draft : String(value)}
        onChange={(e) => setDraft(e.target.value)}
        onBlur={() => { if (draft != null) commit(current()); }}
        onKeyDown={onKeyDown}
        style={{
          width: s.height, minWidth: 0, flex: "none", padding: 0, margin: 0, border: 0,
          background: "transparent", textAlign: "center",
          fontFamily: `var(--dt-text-${s.text}-family)`, fontSize: `var(--dt-text-${s.text}-size)`,
          fontWeight: `var(--dt-text-${s.text}-weight)`, lineHeight: `var(--dt-text-${s.text}-line)`,
          fontVariantNumeric: "tabular-nums",
          color: disabled ? "var(--dt-input-fg-disabled)" : "var(--dt-input-fg)",
          borderRadius: "var(--dt-input-radius)",
        }}
      />
      {button("inc", incOff, () => commit(value + step), increaseLabel, "plus")}
    </div>
  );
}