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
| Size | Class | Button | Count font | Use case |
|---|---|---|---|---|
| Default | (base) | 32px circle | 20px Bricolage / 600 | Expense form, inline pill |
| Compact | .stepper-compact | 24px circle | 15px Bricolage / 600 | Tight layouts, modal rows |
States
Default
3
At minimum (2)
2
At maximum (10)
10
| State | Visual | Notes |
|---|---|---|
| Default | Both buttons ink, count centered | Both decrement and increment available |
| At minimum | Minus button: 30% opacity, disabled | Minimum is 2 — you can't split with fewer than 2 people (including yourself) |
| At maximum | Plus button: 30% opacity, disabled | Maximum is configurable per context; default is 10 |
| Count changed | Count scales to 1.18× then springs back | 200ms 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)
| Part | Element | Notes |
|---|---|---|
| Wrapper | .stepper | Inline-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-count | 20px 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
| Token | Value | Role |
|---|---|---|
--ink | #171513 | Button background |
--s-paper | #FBF8F0 | Button icon color |
--teal-deep | #0A5F4F | Button hover background |
--font-display | Bricolage Grotesque | Count numeral |
--ease-spring | cubic-bezier(0.34,1.56,0.64,1) | Count bump animation |
--d-fast | 140ms | Button transitions |