Components · Forms

Segmented control

A pill track of mutually exclusive options, with the active segment elevated onto a white surface. Use it to switch the mode of one view — Quick charge or Itemised sale, Preview or Code, Daily or Weekly or Monthly.

Documentedby Derek Fidler

Default

Overview

A segmented control is the dense way to switch the interpretationof one surface. The user is not navigating to a new page — the panel they’re looking at is changing how it reads the same underlying state. Quick charge or Itemised sale. Preview or Code. Daily, weekly, or monthly. One binding, N options, one always selected.

Not a tab list

Tabs change which content is visible — separate panes, separate URLs, often separate data fetches. Segmented controlschange how the visible content is rendered. If the body below would be a different section in your information architecture, use Tabs. If it’s the same section showing two ways, use this.

In context

A preview switcher is the canonical case — same template, two modes. The control sits in the toolbar, and the body below swaps its rendering without any layout shift.

Receipt template

Flatpay HQ

Vesterbrogade 26 · 1620 København


Espresso3,50 €

Bavette steak26,00 €


Total29,50 €

Switching the mode of one surface

The classic case: a single panel that changes interpretation. The control sits where a section header would, and the body below switches without a layout shift.

Sizes

Three sizes. md at 32 px is the default — the height of a Button md, so the control sits comfortably in any toolbar that already has buttons. Reach for sm when the row is dense; reach for lg only when the control is the dominant element on the surface.

  • size="sm"28 px

    Inline beside dense filters.

  • size="md"32 px

    Default — toolbar strip, preview switcher.

  • size="lg"40 px

    Standalone, top of a panel.

With icons

With icons

Pair a glyph with each label when the option is symbolic — alignment, density, sort direction. Keep the label even when the icon is recognisable; symbols read faster, words remove ambiguity.

Full width

Full width

Set fullWidth when the control sits inside a form column. Segments grow to fill the row equally — labels still truncate gracefully if the column is narrow.

Disabled

Whole control disabled

When the dependent state can't be edited yet — feature flag off, prerequisite step incomplete.

One option disabled

When a specific option isn't available for the current data — Yearly billing not yet supported, Code view requires permission.

Anatomy

Three named parts. The track holds the segments; the active segment is elevated onto a white surface; the inactive segments sit transparent on the track.

  1. Track

    Pill background in subtler grey (#F4F4F4) with 4 px inner padding so the active thumb has space to breathe.

  2. Inactive segment

    Transparent background, label in text-foreground/65. Hover lifts the label to full foreground; clicking commits the selection.

  3. Active segment

    White surface with a 1 px inset hairline (#E6E6E6) and a 1 px ambient shadow. The label sits in full foreground semibold. Border and shadow are inset so the segment occupies the same box as inactive peers — no layout jitter on toggle.

Behavior

  • One option is always selected. There is no “none” state — the control is fully controlled and the parent must supply a valid initial value. If the underlying data has a meaningful absent state, use a switch or a checkbox group instead.
  • Selection is immediate. Clicking a segment fires onChange right away. No commit step, no Save button — the surface should update on the same paint.
  • Two to four options. The pattern stops working past four — labels truncate, the track grows past comfortable scan width, and the options stop feeling parallel. For five-plus, reach for a Select.
  • Arrow keys move the selection. Tab takes focus into the group, then Left/Right (or Up/Down) advances through options, wrapping at the ends. Home / End jump to first / last. Selection follows focus, matching the radiogroup pattern users already know from RadioCard and Choice list.
  • Body should swap, not reflow. The surface below the control should keep its dimensions across modes. If the new mode genuinely needs more space, you probably want Tabs.

Accessibility

  • Radiogroup semantics. The container is a role="radiogroup"; each segment is a role="radio" with aria-checked. Screen readers announce “X of N selected, [label]” on focus.
  • Roving tabindex.Only the selected segment is in the tab order; arrow keys move focus and selection together through the rest. This is the standard pattern from WAI-ARIA APG’s radio group example.
  • ariaLabel is required.Without it, the group has no announced purpose. Pass a short noun phrase that names the binding: “View mode”, “Sale type”, “Time range”.
  • Focus is visible. The focused segment carries a 2 px blurple ring (#9EB7FF) offset by 1 px from the track — so the focus marker reads against the grey track and the white active surface alike.

Code

tsx

import { useState } from "react";
import { SegmentedControl } from "@flatpay-dk/ui";

type Mode = "preview" | "code";

export function ReceiptPanel() {
  const [mode, setMode] = useState<Mode>("preview");

  return (
    <div>
      <SegmentedControl
        ariaLabel="View mode"
        options={[
          { value: "preview", label: "Preview" },
          { value: "code", label: "Code" },
        ]}
        value={mode}
        onChange={setMode}
      />
      {mode === "preview" ? <ReceiptPreview /> : <ReceiptCode />}
    </div>
  );
}

Best practices

Preview · Code

Do

Use parallel labels of similar length. Two short verbs (Preview / Code) read faster than one short and one long (Preview / Inspect generated source code).

Show all (default) · Show archived

Don't

Don't reach for it when one option is genuinely the default and the other is rare. A switch (or a single button) is simpler than a binary choice the user almost never makes.

Day · Week · Month

Do

Keep it to two-to-four options. Past four, the segments truncate and the parallel-options promise breaks.

Settings ⇄ Members ⇄ Billing

Don't

Don't use it when the new mode would change the page's height, scroll position, or other layout. That's tab navigation.

Props

PropTypeDefaultDescription
options*ReadonlyArray<SegmentedControlOption<T>>The set of mutually exclusive options. Each option needs a value, label, and may carry a leadingIcon and disabled flag.
value*TCurrently selected value. The component is fully controlled — pass the initial value from state.
onChange*(value: T) => voidFires synchronously when the user picks a different option (click, Enter, Space, or arrow-key navigation).
ariaLabel*stringNames the control's purpose for screen readers. Required.
size"sm" | "md" | "lg""md"Track and segment height. md (32 px) is the default.
fullWidthbooleanfalseWhen true, segments grow equally to fill the available width. Use inside form columns; leave off in toolbars.
disabledbooleanfalseDisables the entire control. For per-option disable, set disabled on the option itself.
classNamestringPass-through class on the track wrapper.