Segmented control
A horizontal pill selector for switching between 2–5 mutually exclusive options. The ink-background container holds a paper pill that slides to the active segment with a spring animation. Used for split mode (Equal / Unequal / % / Shares), view toggles, and time range pickers.
Variants
Standard (ink container)
Teal container
Full width
Small
Standard (ink container) — default. Dark pill on paper highlight. Used for split mode selector in the expense calculator — the most prominent usage.
Teal container — use when the segmented control sits on a teal or heavily branded surface. Teal-pale highlight on teal-deep background. Reserved for settle-flow views.
Full width — use .seg--block when the control should fill its container. Segments grow equally. Good for 2-option toggles inside cards.
Small — use .seg--sm for compact surfaces: 12px labels, reduced padding. Used for currency pickers and date range selectors in compact panels.
Sizes
| Size | Class | Font | Padding | Use case |
|---|---|---|---|---|
| Small | .seg--sm | 12px / 500 | 5px 14px | Currency picker, compact filter panels |
| Default | (base) | 14px / 500 | 7px 18px | Split mode, view toggles — standard |
States
| State | Segment pill | Unselected labels | Animation |
|---|---|---|---|
| Unselected | — | 55% opacity paper | — |
| Selected | Paper pill at segment position | 55% opacity paper | — |
| Hover (unselected) | — | 85% opacity paper | 140ms ease-out |
| Pressed | — | scale(0.96) | 140ms ease-out |
| Transition between | Pill slides to new position | Colors cross-fade | 240ms spring |
| Disabled | Frozen | 40% opacity, no cursor | — |
On dark surfaces
On dark surfaces, the container uses --dark-elevated with a dark border inset. The selected pill remains paper — this gives the same ink-on-paper feel regardless of surface. The unselected labels use dark-text-mute.
Animation
The sliding pill is a single absolutely-positioned div.seg__track. On selection change, update its width and transform: translateX() to the selected button's offsetLeft and offsetWidth. The CSS transition (240ms --ease-spring) handles the slide.
The spring easing (cubic-bezier(0.34, 1.56, 0.64, 1)) gives the pill a subtle overshoot that feels tactile — like a physical selector snapping to position.
Anatomy
| Part | Element | Notes |
|---|---|---|
| Container | div.seg[role="tablist"] | Ink background pill. 3px padding all around. position: relative. |
| Track/pill | div.seg__track | Absolute positioned. width and transform updated by JS on selection change. |
| Segment | button.seg__btn[role="tab"] | One per option. aria-selected="true|false". |
Usage
.seg--block) inside a card or panel when you want the control to feel like part of the layout, not floating. Use small (.seg--sm) for secondary controls that shouldn't compete for attention.
Accessibility
role="tablist" on the container and role="tab" on each segment. Set aria-selected="true" on the active segment. Add aria-label to the container describing the group ("Split mode", "View").
Left/Up go to previous, Right/Down go to next. Selection follows focus (auto-activate pattern is appropriate for segmented controls). Tab moves focus out of the control entirely.
prefers-reduced-motion — when enabled, skip the transition and snap the pill instantly (transition: none).
Code
HTML
<div class="seg" role="tablist" aria-label="Split mode"> <!-- Track pill — position updated by JS --> <div class="seg__track" aria-hidden="true"></div> <button class="seg__btn" role="tab" aria-selected="true">Equal</button> <button class="seg__btn" role="tab" aria-selected="false">Unequal</button> <button class="seg__btn" role="tab" aria-selected="false">%</button> <button class="seg__btn" role="tab" aria-selected="false">Shares</button> </div>
JS — slide track on selection
function initSeg(seg) { const track = seg.querySelector('.seg__track'); const btns = seg.querySelectorAll('.seg__btn'); function move(btn) { btns.forEach(b => b.setAttribute('aria-selected', 'false')); btn.setAttribute('aria-selected', 'true'); track.style.width = btn.offsetWidth + 'px'; track.style.transform = 'translateX(' + (btn.offsetLeft - 3) + 'px)'; } btns.forEach(btn => btn.addEventListener('click', () => move(btn))); // Arrow key navigation seg.addEventListener('keydown', e => { const idx = [...btns].indexOf(document.activeElement); if (e.key === 'ArrowRight' && idx < btns.length - 1) { move(btns[idx + 1]); btns[idx + 1].focus(); } if (e.key === 'ArrowLeft' && idx > 0) { move(btns[idx - 1]); btns[idx - 1].focus(); } }); // Init position on load const active = seg.querySelector('.seg__btn[aria-selected="true"]'); if (active) move(active); } document.querySelectorAll('.seg').forEach(initSeg);
CSS classes
| Class | Purpose |
|---|---|
.seg | Ink container pill, flex row, 3px padding |
.seg--sm | Small size — 12px font, reduced padding |
.seg--teal | Teal container variant with teal-pale pill |
.seg--block | Full-width, segments flex to fill |
.seg__track | Sliding paper pill — position driven by JS |
.seg__btn | Individual segment — role="tab" |
Design tokens used
| Token | Value | Role |
|---|---|---|
--ink | #171513 | Container background |
--s-paper | #FBF8F0 | Selected segment pill |
--teal-deep | #0A5F4F | Teal variant container bg |
--teal-pale | #D6EAE2 | Teal variant selected pill |
--ease-spring | cubic-bezier(0.34,1.56,0.64,1) | Pill slide animation |
--d-med | 240ms | Transition duration |
--shadow-sm | 0 1px 0 … | Pill elevation |