Design Lounge
Install

Nº 062 of 520

Sliding segmented control

Segmented controls in 2, 3 and 5-segment variants: an ivory pill slides between options over 260ms with a mid-travel squash, arrow keys move selection, and each switches a content panel.

Preview

Open full size
1280 × 800 · 10.2 KB · 132 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Sliding segmented control" (piece segmented-control-sliding) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

218 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

A segmented control for a banking account page (“Fjord Bank”): a dark 36px track with 3px inset, and one ivory pill that slides to the chosen option. Three variants are shown in a 560px column — two segments (Personal / Business), three segments stretched to full width (Balance / Activity / Cards) and five compact segments (1W / 1M / 3M / 1Y / All) — each switching a small piece of content beneath it with a 140ms crossfade. The detail worth copying is the pill’s travel: left and width transition over 260ms while a keyframe scales it to 1.08 × 0.9 at 40% of the journey and back, so it reads as a physical object being pushed rather than a highlight being re-drawn. Serif numerals for the money; a humanist grotesk for everything else.

Reference behaviour

  1. Initial state: three cards. Card 1: Personal selected; text “3 accounts · kr 212 480,15 combined” beside the control. Card 2: Balance selected; below, “kr 184 320,50” in Cormorant 44px with a green delta line. Card 3: 1M selected; a filled sparkline for the last month.
  2. Click another segment: its label turns --pill-ink (dark) and the previous turns --ink-2; the pill’s left and width animate to the new button’s box over 260ms; simultaneously the squash keyframe runs (scale 1,1 → 1.08,.9 at 40% → 1,1 at 100%). The content pane crossfades: the old pane fades out over 140ms while the new fades in.
  3. Hover a non-selected segment: label brightens to --ink. No pill movement.
  4. Keyboard: Tab lands on the selected segment only (roving tabindex). → / ↓ select the next segment (wrapping), ← / ↑ the previous, Home / End the first / last. Selection follows focus (radio semantics), so the pill slides on every key press.
  5. Focus-visible on a segment: a two-ring box-shadow (2px track colour, then 2px --accent coral).
  6. Card 2’s Activity pane lists four transactions with negative amounts in coral; Cards pane shows two card tiles. Card 3’s five panes are five different sparklines (the same 520×96 viewBox, different point sets).
  7. Resize: the pill is re-measured and re-placed without animation. Fonts loading late also re-place it.

Structure

1280 × 800
┌──────────────────────────────────────────────────────────────────────────┐
│ Fjord Bank  Everyday account · 1203.45.67890              Mara Lindqvist │ 56
├──────────────────────────────────────────────────────────────────────────┤
│                    ┌──────────── 560 ────────────┐                       │
│                    │ TWO SEGMENTS      account scope│ card pad 20/22      │
│                    │ [▐Personal▌ Business]  3 accounts · kr 212 480,15 │ │ seg 36 tall
│                    ├──────────────────────────────┤                       │
│                    │ THREE SEGMENTS      account view│                      │
│                    │ [▐  Balance  ▌  Activity   Cards ]  ← full width     │
│                    │ kr 184 320,50                  │ Cormorant 44        │
│                    │ Available · + 2 140,00 since Monday · 3 pending    │ │
│                    ├──────────────────────────────┤                       │
│                    │ FIVE SEGMENTS       chart range│                      │
│                    │ [ 1W ▐1M▌ 3M  1Y  All ]        │ seg 32 tall         │
│                    │ ╱╲╱‾‾╲╱‾‾‾╱ sparkline 96 tall  │                      │
│                    └──────────────────────────────┘                       │
└──────────────────────────────────────────────────────────────────────────┘
  • <main> — 560px column, centred, 36px top padding, 22px gap between .block cards (--panel, 1px --line, 14px radius, padding 20px 22px).
  • Each card: <p class="cap"> (11px uppercase caption + right-aligned 12px note), then the control, then .panes.
  • .seg — <div role="radiogroup" aria-label data-panes="<id>">, position:relative; display:inline-grid; grid-auto-flow:column; grid-auto-columns:1fr, --track background, 1px --line border, 10px radius, 3px padding, height 36px (.full variant: display:grid to stretch; the 5-segment variant overrides --seg-h: 32px).
    • <button role="radio" aria-checked tabindex> × n — z-index:1, padding 0 16px, min-width 56px, radius 7px (10 − 3).
    • <span class="pill" aria-hidden> — absolute, top/bottom: 3px, left/width set by JS, --pill fill, radius 7px, shadow 0 1px 2px rgba(0,0,0,.35).
  • .panes — one .pane per segment; the active one is position:relative and visible, the rest absolute + hidden.

Pane content

Card 1 (2 segments) — Personal: “3 accounts · kr 212 480,15 combined”. Business: “1 account · Lindqvist Studio AS · kr 58 900,00”. Rendered inline beside the control (13px --ink-2).

Card 2 (3 segments) — Balance: kr (22px --ink-3) 184 320,50 (Cormorant 44/500) and meta “Available · + 2 140,00 since Monday · 3 pending” (delta in --positive). Activity: four rows (date --ink-3 · description · amount right-aligned, negatives in --accent): Mon 28 Sep Kaffebrenneriet Grünerløkka − 64,00 · Mon 28 Sep Salary · Halden Logistics + 41 200,00 · Sun 27 Sep Vy · Bergen–Oslo − 899,00 · Sat 26 Sep Meny Torshov − 1 212,40. Cards: two tiles (1px --line-2, 10px radius, 12px 14px padding): ”•••• 4471 — Debit · expires 09/28 · online payments on” and ”•••• 0182 — Credit · kr 12 000 limit · 0,00 used”.

Card 3 (5 segments) — five sparklines, each an SVG viewBox="0 0 520 96" with preserveAspectRatio="none", 96px tall, a 1.5px --ink polyline and a fill path at rgba(242,237,228,.06) closed to the bottom edge:

SegmentPolyline points (x y …)
1W0 60, 74 58, 148 62, 222 40, 296 44, 370 30, 444 34, 520 26
1M0 70, 52 66, 104 72, 156 58, 208 60, 260 44, 312 50, 364 36, 416 40, 468 24, 520 28
3M0 80, 65 74, 130 78, 195 60, 260 64, 325 46, 390 52, 455 30, 520 22
1Y0 88, 87 80, 174 84, 261 62, 348 66, 435 38, 520 20
All0 92, 104 86, 208 76, 312 60, 416 40, 520 18

The fill path is the same points followed by V96 H0 Z.

Edge cases

  • Clicking the already-selected segment: no-op (no squash replay).
  • Keyboard selection calls select(j, true) which also focuses the new segment; pointer selection does not move focus.
  • Pane switching toggles .on; the .panes container’s height follows the active pane, so a card may change height between panes — acceptable, no height animation.
  • Initial placement happens before first paint (script at end of body) and again on document.fonts.ready, because Karla’s metrics differ from the fallback and shift every button’s width.

Tokens

:root {
  /* colour — warm charcoal, ivory pill, coral accent */
  --bg: #151412;
  --panel: #1c1a17;       /* cards */
  --track: #26231f;       /* control track */
  --line: #2f2b26;        /* hairlines, track border */
  --line-2: #3d382f;      /* card tiles */
  --ink: #f2ede4;
  --ink-2: #b3ab9e;       /* unselected labels */
  --ink-3: #7c766c;       /* captions, meta */
  --pill: #f2ede4;
  --pill-ink: #151412;    /* selected label */
  --accent: #ef6f5e;      /* focus ring, negative amounts */
  --positive: #8cc9a0;

  /* type */
  --serif: "Cormorant Garamond", Georgia, serif;
  --sans: "Karla", system-ui, sans-serif;

  /* layout */
  --seg-h: 36px;          /* 32px on the compact variant */
  --seg-pad: 3px;
  --seg-min: 56px;        /* min segment width */
  --r: 10px;              /* track */
  --r-pill: 7px;          /* --r minus --seg-pad */
  --r-card: 14px;
  --col: 560px;
  --shadow-pill: 0 1px 2px rgba(0,0,0,.35);

  /* motion */
  --t-fast: 140ms;        /* label colour, pane crossfade */
  --t-slide: 260ms;       /* pill travel + squash */
  --ease: cubic-bezier(.2, .7, .2, 1);
  --ease-out: cubic-bezier(.16, 1, .3, 1);
}

Typography

RoleFamilySizeWeightLine-heightTrackingNotes
BrandCormorant Garamond22px6001+0.01em
Segment labelKarla13px50010--ink-2; selected --pill-ink; hover --ink
Card captionKarla11px6001.3+0.12emUPPERCASE --ink-3; note 12px 400 no tracking
Balance amountCormorant Garamond44px5001−0.01emtabular-nums; “kr” 22px --ink-3
Meta / listKarla13px4001.50amounts 500 tabular-nums
Card tile numberCormorant Garamond16px6001.3+0.06em
Header metaKarla13px4001.50--ink-3

Motion

ElementTriggerPropertyFrom → ToDurationEasingNotes
.pillselectleft, widthold button box → new button box260ms--easemeasured with offsetLeft / offsetWidth
.pillselecttransform (keyframes squash)scale(1,1) → scale(1.08,.9) @40% → scale(1,1)260ms--easerestarted by removing/re-adding .go after a reflow
segment labelselect / hovercolor--ink-2 ↔ --pill-ink / --ink140ms--ease
.paneselectopacity1 → 0 (old), 0 → 1 (new)140ms--easeold pane’s visibility flips after 140ms
.pillresize / fontsleft, widthre-measured0—no .go; the transition still applies unless you disable it — acceptable

Reduced motion: all transitions 1ms and the squash animation removed (animation: none).

States

  • Segment unselected: --ink-2, transparent. Hover: --ink. Selected (aria-checked="true"): --pill-ink over the pill.
  • Focus-visible: box-shadow: 0 0 0 2px var(--track), 0 0 0 4px var(--accent); the ring is on the button, above the pill (z-index:1).
  • Pill: always exactly one, always under the selected segment, never hidden.
  • Disabled (not shown): if you need it, --ink-3 label with cursor:default and skip it in arrow-key traversal.
  • Negative amount: --accent. Positive delta: --positive.

Accessibility

  • Pattern: a radio group. Container role="radiogroup" aria-label="Account view"; options role="radio" aria-checked="true|false".
  • Roving tabindex: the checked option has tabindex="0", others -1. Arrow keys move and select (matching native radios); Home/End jump.
  • The pill is aria-hidden="true"; state is conveyed by aria-checked only.
  • Content panes: inactive panes are visibility:hidden so their contents leave the accessibility tree and tab order. If the panes contain focusable content, prefer role="tablist" / role="tab" / role="tabpanel" semantics instead — the visual is identical.
  • Sparklines have aria-labels (“Balance, last month”); treat them as decorative if the numbers are stated elsewhere.
  • Contrast: --pill-ink on --pill 15:1; --ink-2 on --track 7.4:1; --ink-3 on --panel 3.9:1 (captions only).
  • Hit targets: segments 30px tall inside the 36px track (26px in the compact variant) — for touch, bump --seg-h to 40px.

Responsive rules

  • ≥ 1280: as specified.
  • 1024–1279: unchanged.
  • 768–1023: column min(560px, calc(100vw − 48px)); the 2-segment card stacks its text under the control.
  • < 640: all controls display:grid (full width); the 5-segment variant’s labels drop to 12px; --seg-h: 40px for touch.

Acceptance checklist

  • Track is 36px tall (32px compact), 3px padding, 10px radius, 1px #2f2b26 border on #26231f.
  • Pill is #f2ede4, 7px radius, spans exactly the selected button’s offsetLeft/offsetWidth.
  • Selecting a segment slides the pill over 260ms cubic-bezier(.2,.7,.2,1) and the squash reaches scale(1.08,.9) at 40%.
  • Labels transition colour over 140ms; selected label is dark on the pill.
  • Only the selected segment is in the Tab order; → ← ↓ ↑ Home End move selection with wrapping.
  • Focus ring is a 2px track-coloured gap plus 2px coral ring.
  • Each control switches its own pane group; panes crossfade over 140ms and hidden panes are visibility:hidden.
  • Three variants render: 2 segments inline, 3 segments full-width, 5 segments compact.
  • Pill is re-measured on resize and after document.fonts.ready.
  • Reduced motion: no squash, no slide; state changes still happen.

Implementation notes

Measure, don’t compute. Segment widths depend on text; place the pill from the button’s box:

function place(btn, animate) {
  pill.style.left = btn.offsetLeft + 'px';
  pill.style.width = btn.offsetWidth + 'px';
  if (animate) { pill.classList.remove('go'); void pill.offsetWidth; pill.classList.add('go'); }  // restart keyframes
}

offsetLeft is relative to the track’s padding box, which is also what left positions against — so the 1px border cancels out. Don’t use getBoundingClientRect differences here; they drift under transforms.

Squash lives on transform, travel on left/width. Keeping them on different properties means the keyframe can’t fight the transition:

.pill { position: absolute; top: 3px; bottom: 3px; transform-origin: center;
        transition: left 260ms var(--ease), width 260ms var(--ease); }
.pill.go { animation: squash 260ms var(--ease); }
@keyframes squash { 0% { transform: scale(1,1) } 40% { transform: scale(1.08,.9) } 100% { transform: scale(1,1) } }

Radio keyboard semantics (selection follows focus):

btn.addEventListener('keydown', e => {
  const n = btns.length; let j = null;
  if (e.key === 'ArrowRight' || e.key === 'ArrowDown') j = (i + 1) % n;
  else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') j = (i - 1 + n) % n;
  else if (e.key === 'Home') j = 0; else if (e.key === 'End') j = n - 1;
  if (j !== null) { e.preventDefault(); select(j, /*focus*/ true); }
});

Common mistakes: giving the pill transform: translateX() computed from index × width (breaks with unequal labels); animating with all so the initial placement slides in from 0 on load (set it before first paint or temporarily disable transitions); forgetting void pill.offsetWidth so the squash only plays once.

Details

Palette
Type
Cormorant Garamond · Karla
Motion
Subtle motion
Build
Quick build
Designed bySusan AcharyaKathmandu

Studied

Tailwind CSS docs

Put each instruction next to its code. Draw the grid with thin lines and let the empty sides carry a faint hatch.

All sources