Skip to content

Segmented control

A small radio group drawn as a track with a sliding thumb, for two to five modes.

Status
Stable
Category
Selection
Adoption
Not used yet
import { SegmentedControl } from "@oration/canon/components/segmented-control";
packages/canon/src/components/segmented-control.tsx

Invoices

  • INV-20418Northwind Freight$18,240.00
  • INV-20411Halcyon$4,120.50
  • INV-20407Orchard Street$960.00
  • INV-20399Brightline Freight$12,875.00
  • INV-20392Keystone Software$2,400.00
import { SegmentedControl } from "@oration/canon/components/segmented-control";import * as React from "react";export function Hero() {    type Filter = "all" | "open" | "overdue" | "paid";    const invoices = [        {            id: "INV-20418",            supplier: "Northwind Freight",            amount: "$18,240.00",            status: "open",        },        {            id: "INV-20411",            supplier: "Halcyon",            amount: "$4,120.50",            status: "overdue",        },        {            id: "INV-20407",            supplier: "Orchard Street",            amount: "$960.00",            status: "paid",        },        {            id: "INV-20399",            supplier: "Brightline Freight",            amount: "$12,875.00",            status: "open",        },        {            id: "INV-20392",            supplier: "Keystone Software",            amount: "$2,400.00",            status: "paid",        },    ];    const [filter, setFilter] = React.useState<Filter>("all");    const count = (status: string) =>        invoices.filter((invoice) => invoice.status === status).length;    const rows = invoices.filter(        (invoice) => filter === "all" || invoice.status === filter,    );    return (        <div className="w-full max-w-lg overflow-hidden rounded-xl bg-card text-left shadow-border">            <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">                <p className="text-sm font-medium">Invoices</p>                <SegmentedControl<Filter>                    label="Filter invoices by status"                    value={filter}                    onValueChange={setFilter}                    className="text-[13px]"                    options={[                        { value: "all", label: `All ${invoices.length}` },                        { value: "open", label: `Open ${count("open")}` },                        {                            value: "overdue",                            label: `Overdue ${count("overdue")}`,                        },                        { value: "paid", label: `Paid ${count("paid")}` },                    ]}                />            </div>            <ul className="divide-y divide-border">                {rows.map((invoice) => (                    <li                        key={invoice.id}                        className="flex h-9 items-center gap-3 px-3 text-13"                    >                        <span className="w-20 font-mono text-xs text-muted-foreground">                            {invoice.id}                        </span>                        <span className="flex-1 truncate">                            {invoice.supplier}                        </span>                        <span className="font-medium tabular-nums">                            {invoice.amount}                        </span>                    </li>                ))}            </ul>        </div>    );}

Usage#

Segmented control is a small radio group drawn as a Well Gray track with a white thumb that slides to the chosen option. It switches the mode of what is already on screen: a list filter, a date range, 12h or 24h, list or board. It is one of the most used controls in the suite. The common mistake is using it to switch between different panels of content, which is the job of Tabs, or stretching it past five options, where the labels start to wrap and overflow.

When to use

  • To filter or re-sort the list or chart right below it: All, Open, Overdue, Paid.
  • For a date range on a chart or report: 7 days, 30 days, 90 days.
  • For a binary format setting that applies at once, such as 12h and 24h.
  • To switch the view of the same records, such as list, board and calendar, with icons.
  • When all two to five options should stay visible and the choice applies immediately.

When not to use

  • To move between panels with different content, such as a record's Overview and Activity. Use Tabs
  • For six or more options, or options with long labels. Use Select field
  • When several options can be on at once, such as formatting or channel filters. Use Toggle group
  • For a choice in a form that needs a description per option. Use Choice card
  • For an on and off setting. Use Switch

Two to five modes

Every option is a short word or two, and all of them fit on one line at the narrowest width the view supports. Past five, use a select.

Every group has a name

label is required and becomes the radio group's aria-label. Name what is being chosen, such as Filter invoices by status, even when a visible heading sits beside it.

Anatomy#

  1. Track. A 28px Well Gray pill with 2px padding and 10px corners. It hugs its options.
  2. Segment. A 24px button with 8px corners and 8px side padding. Unselected segments are Slate Meta.
  3. Thumb. White Plane with the hairline lift behind the selected segment. It slides between segments on a 0.3s spring with no bounce.
  4. Label. Medium weight in ink when selected, regular in Slate Meta otherwise.
  5. Icon. Optional, 14px, before the label. With hideLabel, the icon stands alone and the label becomes the aria-label.

Examples#

Two and three options

A binary format and a short range. The thumb slides on a 0.3s spring with no bounce; click or use the arrow keys.

import { SegmentedControl } from "@oration/canon/components/segmented-control";import * as React from "react";export function TwoAndThree() {    const [format, setFormat] = React.useState<"12" | "24">("12");    const [range, setRange] = React.useState<"week" | "month" | "quarter">(        "month",    );    return (        <div className="flex flex-col items-center gap-4 text-[13px]">            <SegmentedControl                label="Time format"                value={format}                onValueChange={setFormat}                options={[                    { value: "12", label: "12h" },                    { value: "24", label: "24h" },                ]}            />            <SegmentedControl                label="Payment calendar range"                value={range}                onValueChange={setRange}                options={[                    { value: "week", label: "Week" },                    { value: "month", label: "Month" },                    { value: "quarter", label: "Quarter" },                ]}            />        </div>    );}

With icons

A 14px icon before each label, for views of the same records.

import { SegmentedControl } from "@oration/canon/components/segmented-control";import { toast } from "@oration/canon/components/toast";import { CalendarDaysIcon, KanbanSquareIcon, ListIcon } from "lucide-react";import * as React from "react";export function WithIcons() {    const [view, setView] = React.useState<"list" | "board" | "calendar">(        "list",    );    return (        <div className="flex flex-col items-center gap-3 text-[13px]">            <SegmentedControl                label="Payment run view"                value={view}                onValueChange={(next) => {                    setView(next);                    toast.add({ title: `Showing the ${next} view` });                }}                options={[                    {                        value: "list",                        label: "List",                        icon: <ListIcon aria-hidden="true" />,                    },                    {                        value: "board",                        label: "Board",                        icon: <KanbanSquareIcon aria-hidden="true" />,                    },                    {                        value: "calendar",                        label: "Calendar",                        icon: <CalendarDaysIcon aria-hidden="true" />,                    },                ]}            />        </div>    );}

Icon only

hideLabel shows the icon alone and moves the label to aria-label. There's no tooltip, so keep a visible label beside the control.

Row density
import { SegmentedControl } from "@oration/canon/components/segmented-control";import { toast } from "@oration/canon/components/toast";import { ListIcon, Rows3Icon } from "lucide-react";import * as React from "react";export function IconOnly() {    const [density, setDensity] = React.useState<"comfortable" | "compact">(        "comfortable",    );    return (        <div className="flex items-center gap-3 text-[13px]">            <span className="text-muted-foreground">Row density</span>            <SegmentedControl                label="Row density"                value={density}                onValueChange={(next) => {                    setDensity(next);                    toast.add({                        title: `${next === "compact" ? "Compact" : "Comfortable"} rows`,                    });                }}                options={[                    {                        value: "comfortable",                        label: "Comfortable",                        icon: <Rows3Icon aria-hidden="true" />,                        hideLabel: true,                    },                    {                        value: "compact",                        label: "Compact",                        icon: <ListIcon aria-hidden="true" />,                        hideLabel: true,                    },                ]}            />        </div>    );}

In a card header

A date range beside a chart's title and total. The control changes what the card shows; it doesn't switch to another card.

Spend with top suppliers

$212,640

  • Northwind Freight$96,120
  • Halcyon$71,300
  • Orchard Street$45,220
import { SegmentedControl } from "@oration/canon/components/segmented-control";import * as React from "react";export function CardHeader() {    type Range = "7" | "30" | "90";    const data: Record<        Range,        {            total: string;            suppliers: { name: string; spend: string; share: number }[];        }    > = {        "7": {            total: "$48,210",            suppliers: [                { name: "Northwind Freight", spend: "$22,400", share: 46 },                { name: "Halcyon", spend: "$14,030", share: 29 },                { name: "Orchard Street", spend: "$11,780", share: 25 },            ],        },        "30": {            total: "$212,640",            suppliers: [                { name: "Northwind Freight", spend: "$96,120", share: 45 },                { name: "Halcyon", spend: "$71,300", share: 34 },                { name: "Orchard Street", spend: "$45,220", share: 21 },            ],        },        "90": {            total: "$604,905",            suppliers: [                { name: "Northwind Freight", spend: "$288,400", share: 48 },                { name: "Halcyon", spend: "$190,505", share: 31 },                { name: "Orchard Street", spend: "$126,000", share: 21 },            ],        },    };    const [range, setRange] = React.useState<Range>("30");    const current = data[range];    return (        <div className="flex w-full max-w-md flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border">            <div className="flex items-start justify-between gap-3">                <div>                    <p className="text-13 text-muted-foreground">                        Spend with top suppliers                    </p>                    <p className="text-2xl font-semibold tabular-nums">                        {current.total}                    </p>                </div>                <SegmentedControl<Range>                    label="Date range"                    value={range}                    onValueChange={setRange}                    options={[                        { value: "7", label: "7 days" },                        { value: "30", label: "30 days" },                        { value: "90", label: "90 days" },                    ]}                    className="text-[13px]"                />            </div>            <ul className="flex flex-col gap-2.5">                {current.suppliers.map((supplier) => (                    <li key={supplier.name} className="flex flex-col gap-1">                        <div className="flex justify-between text-13">                            <span>{supplier.name}</span>                            <span className="tabular-nums">                                {supplier.spend}                            </span>                        </div>                        <div className="h-1.5 rounded-full bg-muted">                            <div                                className="h-full rounded-full bg-foreground/65 transition-[width] duration-500 ease-out"                                style={{ width: `${supplier.share}%` }}                            />                        </div>                    </li>                ))}            </ul>        </div>    );}

States#

Rest
Hover on Open
Focus on All
import { SegmentedControl } from "@oration/canon/components/segmented-control";import { cn } from "@oration/canon/lib/utils";export function StatesRow() {    const states = [        { name: "Rest", className: "" },        {            name: "Hover on Open",            className: "[&>button:nth-child(2)]:text-foreground",        },        {            name: "Focus on All",            className:                "[&>button:nth-child(1)]:ring-3 [&>button:nth-child(1)]:ring-ring/40",        },    ];    return (        <div className="grid w-full gap-4 sm:grid-cols-3">            {states.map((state) => (                <div                    key={state.name}                    className="flex flex-col items-start gap-2"                >                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <SegmentedControl                        label={`Status, ${state.name.toLowerCase()}`}                        value="all"                        onValueChange={() => undefined}                        options={[                            { value: "all", label: "All" },                            { value: "open", label: "Open" },                            { value: "paid", label: "Paid" },                        ]}                        className={cn(                            "pointer-events-none text-[13px]",                            state.className,                        )}                    />                </div>            ))}        </div>    );}
States
StateTreatment
RestSlate Meta label on the Well Gray track.
HoverThe label darkens to ink over 150ms. No fill.
SelectedThe white thumb with hairline lift sits behind it, and the label turns medium weight in ink.
Focus visibleA 3px Focus Indigo ring at 40% around the focused segment.
MovingThe thumb slides to the new segment on a 0.3s spring. With reduced motion, the app's motion config makes it jump.

Behavior#

  • Controlled only: pass value and update it in onValueChange. The value type narrows to the option values, so SegmentedControl<Filter> rejects a typo.
  • Clicking a segment selects it. Arrow keys move focus and select in one step, wrapping at the ends, so the choice applies as people move.
  • Only the selected segment is in the tab order; Tab enters and leaves the group in one stop.
  • Each instance has its own thumb, so several controls on one page animate independently.
  • The track hugs its content. w-full widens the track but not the segments; add [&>button]:flex-1 to stretch them.
  • Counts in labels come from the string you pass, such as Open 48. Update them as the list changes.

Do and don't#

Do. Keep to two to five short options that fit on one line.
Don't. Put seven weekdays in a segmented control. It overflows, and a run day belongs in a select.
Do. Use it to change the mode of the list or chart right beside it.
Don't. Use it to switch between panels of different content. That's Tabs, with its tab and panel semantics.

Content#

  • One or two words per option, sentence case, parallel in form: Week, Month, Quarter.
  • Counts follow the word with a space: Overdue 9. No brackets or dots.
  • Spell out units: 7 days, not 7d, except for established forms such as 12h.
  • The label names the choice as a phrase: Filter invoices by status, Time format.

Accessibility#

  • The track is role="radiogroup" with aria-label from label; each segment is role="radio" with aria-checked.
  • Icon-only segments (hideLabel) get the label as aria-label. There's no tooltip, so pick icons people already know.
  • Mark icons aria-hidden="true"; the label names the option.
  • Segments are 24px tall, the minimum target size. Don't shrink them further on touch layouts.
  • Selection follows focus, so don't attach slow work to a change without a pending state.
Keyboard interactions
KeysAction
TabMoves focus to the selected segment, then out of the group.
→Selects the next option, wrapping to the first.
←Selects the previous option, wrapping to the last.
SpaceSelects the focused option (it already is, after arrow keys).

Design tokens#

Design tokens
TokenUsed for
--mutedTrack fill
--backgroundThumb fill
shadow-borderThumb hairline lift
--foregroundSelected and hovered label
--muted-foregroundUnselected label
--ring3px focus ring at 40%
--radius-lg10px track corners; 8px segments

API reference#

SegmentedControl

The track, segments and thumb. Generic over the option value type T extends string.

Props of SegmentedControl
PropTypeDefaultDescription
valueRequiredTNo defaultThe selected option's value.
onValueChangeRequired(value: T) => voidNo defaultCalled on click and on arrow keys.
optionsRequiredSegmentedOption<T>[]No defaultTwo to five options, in order.
labelRequiredstringNo defaultThe radio group's accessible name.
classNamestringNo defaultMerged onto the track. Pass text-[13px] so the labels get their size (see Known gaps).

SegmentedOption

One option. Exported as a type.

Props of SegmentedOption
PropTypeDefaultDescription
valueRequiredTNo defaultThe value reported on change.
labelRequiredstringNo defaultThe visible label, or the aria-label with hideLabel.
iconReactNodeNo defaultA 14px icon before the label.
hideLabelbooleanfalseShows only the icon and moves the label to aria-label.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Segment labels are meant to be 13px, but the cn package drops text-13 next to the text color, so they inherit the parent's size, usually 14px. Pass className="text-[13px]" on the track until it's fixed.

There's no disabled prop for the control or for single options, and no size other than 28px.

Only ← and → move the selection. The radio group pattern also expects ↑ and ↓, and Home and End aren't handled.

Icon-only segments have no tooltip, unlike icon buttons elsewhere, so sighted people get no name for the icon.

Counts in labels are plain strings in proportional figures, so segment widths shift as counts change, against the Tabular Figures Rule.