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

SizeClassFontPaddingUse case
Small.seg--sm12px / 5005px 14pxCurrency picker, compact filter panels
Default(base)14px / 5007px 18pxSplit mode, view toggles — standard

States

StateSegment pillUnselected labelsAnimation
Unselected55% opacity paper
SelectedPaper pill at segment position55% opacity paper
Hover (unselected)85% opacity paper140ms ease-out
Pressedscale(0.96)140ms ease-out
Transition betweenPill slides to new positionColors cross-fade240ms spring
DisabledFrozen40% 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

Container (.seg, ink bg)
Sliding pill (.seg__track)
Unselected segment
PartElementNotes
Containerdiv.seg[role="tablist"]Ink background pill. 3px padding all around. position: relative.
Track/pilldiv.seg__trackAbsolute positioned. width and transform updated by JS on selection change.
Segmentbutton.seg__btn[role="tab"]One per option. aria-selected="true|false".

Usage

Do Use for 2–5 mutually exclusive options that are always visible. The entire set should fit on one line without wrapping. Short labels only: "Equal", "Unequal", "%", "Shares" — never more than 2 words per segment.
Don't Don't use for more than 5 options — use a select or radio group. Don't use for options that have sub-options or state — it's a flat toggle, not a nav pattern. Don't let it wrap to two lines.
Do Use full-width (.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.
Don't Don't use segmented controls as navigation (use tab bar for that). Don't pre-select a segment that isn't the logical default — the first option should almost always be the default.

Accessibility

ARIA Use 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").
Keyboard Arrow keys navigate between segments within the control. 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.
Motion The spring slide animation is purely aesthetic. Respect 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

ClassPurpose
.segInk container pill, flex row, 3px padding
.seg--smSmall size — 12px font, reduced padding
.seg--tealTeal container variant with teal-pale pill
.seg--blockFull-width, segments flex to fill
.seg__trackSliding paper pill — position driven by JS
.seg__btnIndividual segment — role="tab"

Design tokens used

TokenValueRole
--ink#171513Container background
--s-paper#FBF8F0Selected segment pill
--teal-deep#0A5F4FTeal variant container bg
--teal-pale#D6EAE2Teal variant selected pill
--ease-springcubic-bezier(0.34,1.56,0.64,1)Pill slide animation
--d-med240msTransition duration
--shadow-sm0 1px 0 …Pill elevation