Stepper

A numeric incrementer for adjusting counts — primarily used in "split with" to set how many people share an expense. Two ink circles flanking a bold display number. Simple, tactile, impossible to misread.

Variants

Default
4
Inline
Split with
3
people

Default is the bare stepper — two circle buttons, one count. Used when the surrounding UI provides enough context ("split with" label is elsewhere on the screen).

Inline wraps the stepper in a pill container with text labels on either side. Used in the expense form where the stepper needs to stand alone without surrounding context.

Sizes

Default — 32px buttons
2
Compact — 24px buttons
2
SizeClassButtonCount fontUse case
Default(base)32px circle20px Bricolage / 600Expense form, inline pill
Compact.stepper-compact24px circle15px Bricolage / 600Tight layouts, modal rows

States

Default
3
At minimum (2)
2
At maximum (10)
10
StateVisualNotes
DefaultBoth buttons ink, count centeredBoth decrement and increment available
At minimumMinus button: 30% opacity, disabledMinimum is 2 — you can't split with fewer than 2 people (including yourself)
At maximumPlus button: 30% opacity, disabledMaximum is configurable per context; default is 10
Count changedCount scales to 1.18× then springs back200ms spring — confirms the tap registered

On dark surfaces

Default on dark
4
At minimum on dark
2

On dark surfaces the circle buttons invert to paper-on-dark — --dark-text fill with dark canvas text. The count uses --dark-text. The inline pill variant uses --dark-elevated as its container background.

Anatomy

4
Decrement (stepper-btn)
Count (stepper-count, 20px Bricolage)
Increment (stepper-btn)
PartElementNotes
Wrapper.stepperInline-flex, user-select: none. Width is determined by content — never stretch it.
Decrement button.stepper-btn ()32px circle, ink fill. Disabled at min value. Requires aria-label="Decrease".
Count.stepper-count20px Bricolage 600. Min-width 28px so the layout doesn't shift between single and double digits. Animates on change.
Increment button.stepper-btn (+)32px circle, ink fill. Disabled at max value. Requires aria-label="Increase".

Usage

Do Set a minimum of 2 — splitting with yourself doesn't make sense. Trigger the count bump animation on every value change. Provide aria-label and aria-valuenow on the wrapper.
Don't Don't allow arbitrary text input — the stepper is intentionally opinionated. Don't use it for non-integer values. Don't place two steppers directly adjacent without a clear visual separator.
Do Use the inline variant when the stepper is the primary input on a screen. Use the default variant when context (a label, a row) already explains what the count means.
Don't Don't use stepper for large ranges (e.g., 1–100) where a direct input would be faster. Don't remove the count bump animation — it's the only feedback that the tap registered.

Accessibility

ARIA Wrap the stepper in a container with role="group" and aria-label="Number of people". Each button gets a descriptive aria-label ("Increase number of people"). The count element should have aria-live="polite" so screen readers announce changes without interrupting.
Keyboard Both buttons are naturally focusable via Tab and activated by Enter or Space. Focus ring: 2px solid var(--teal) with 2px offset. Disabled buttons should not receive focus — use the disabled attribute, not just aria-disabled.
Touch target 32px buttons are at the lower boundary of comfortable touch targets. On mobile, consider padding the surrounding area to increase effective hit area without changing the visual size.

Code

HTML

<!-- Default stepper, min=2, max=10 -->
<div class="stepper" role="group" aria-label="Number of people">
  <button class="stepper-btn" aria-label="Decrease" disabled></button>
  <span class="stepper-count" aria-live="polite">2</span>
  <button class="stepper-btn" aria-label="Increase">+</button>
</div>

<!-- Inline variant -->
<div class="stepper-inline">
  <span class="stepper-label">Split with</span>
  <div class="stepper" role="group" aria-label="Number of people">
    <button class="stepper-btn" aria-label="Decrease"></button>
    <span class="stepper-count" aria-live="polite">3</span>
    <button class="stepper-btn" aria-label="Increase">+</button>
  </div>
  <span class="stepper-label">people</span>
</div>

JavaScript — count bump + min/max

function stepperChange(id, delta) {
  const el = document.getElementById(id + '-count');
  const stepper = document.getElementById(id);
  const min = 2, max = 10;
  let val = parseInt(el.textContent) + delta;
  val = Math.max(min, Math.min(max, val));
  el.textContent = val;
  // Bump animation
  el.classList.remove('bumping');
  el.offsetWidth; // reflow
  el.classList.add('bumping');
  // Enforce disabled states
  stepper.querySelector('[aria-label="Decrease"]').disabled = val <= min;
  stepper.querySelector('[aria-label="Increase"]').disabled = val >= max;
}

Design tokens used

TokenValueRole
--ink#171513Button background
--s-paper#FBF8F0Button icon color
--teal-deep#0A5F4FButton hover background
--font-displayBricolage GrotesqueCount numeral
--ease-springcubic-bezier(0.34,1.56,0.64,1)Count bump animation
--d-fast140msButton transitions