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 pxInline beside dense filters.
size="md"32 pxDefault — toolbar strip, preview switcher.
size="lg"40 pxStandalone, 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.
Track
Pill background in subtler grey (
#F4F4F4) with 4 px inner padding so the active thumb has space to breathe.Inactive segment
Transparent background, label in
text-foreground/65. Hover lifts the label to full foreground; clicking commits the selection.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
onChangeright 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 arole="radio"witharia-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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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* | T | — | Currently selected value. The component is fully controlled — pass the initial value from state. |
| onChange* | (value: T) => void | — | Fires synchronously when the user picks a different option (click, Enter, Space, or arrow-key navigation). |
| ariaLabel* | string | — | Names the control's purpose for screen readers. Required. |
| size | "sm" | "md" | "lg" | "md" | Track and segment height. md (32 px) is the default. |
| fullWidth | boolean | false | When true, segments grow equally to fill the available width. Use inside form columns; leave off in toolbars. |
| disabled | boolean | false | Disables the entire control. For per-option disable, set disabled on the option itself. |
| className | string | — | Pass-through class on the track wrapper. |