Segmented control
A small radio group drawn as a track with a sliding thumb, for two to five modes.
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 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#
- Track. A 28px Well Gray pill with 2px padding and 10px corners. It hugs its options.
- Segment. A 24px button with 8px corners and 8px side padding. Unselected segments are Slate Meta.
- Thumb. White Plane with the hairline lift behind the selected segment. It slides between segments on a 0.3s spring with no bounce.
- Label. Medium weight in ink when selected, regular in Slate Meta otherwise.
- Icon. Optional, 14px, before the label. With
hideLabel, the icon stands alone and the label becomes thearia-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.
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#
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> );}| State | Treatment |
|---|---|
| Rest | Slate Meta label on the Well Gray track. |
| Hover | The label darkens to ink over 150ms. No fill. |
| Selected | The white thumb with hairline lift sits behind it, and the label turns medium weight in ink. |
| Focus visible | A 3px Focus Indigo ring at 40% around the focused segment. |
| Moving | The 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
valueand update it inonValueChange. The value type narrows to the option values, soSegmentedControl<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-fullwidens the track but not the segments; add[&>button]:flex-1to 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#
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
labelnames the choice as a phrase: Filter invoices by status, Time format.
Accessibility#
- The track is
role="radiogroup"witharia-labelfromlabel; each segment isrole="radio"witharia-checked. - Icon-only segments (
hideLabel) get the label asaria-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.
| Keys | Action |
|---|---|
| Tab | Moves 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. |
| Space | Selects the focused option (it already is, after arrow keys). |
Design tokens#
| Token | Used for |
|---|---|
--muted | Track fill |
--background | Thumb fill |
shadow-border | Thumb hairline lift |
--foreground | Selected and hovered label |
--muted-foreground | Unselected label |
--ring | 3px focus ring at 40% |
--radius-lg | 10px track corners; 8px segments |
API reference#
SegmentedControl
The track, segments and thumb. Generic over the option value type T extends string.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | T | No default | The selected option's value. |
onValueChangeRequired | (value: T) => void | No default | Called on click and on arrow keys. |
optionsRequired | SegmentedOption<T>[] | No default | Two to five options, in order. |
labelRequired | string | No default | The radio group's accessible name. |
className | string | No default | Merged onto the track. Pass text-[13px] so the labels get their size (see Known gaps). |
SegmentedOption
One option. Exported as a type.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | T | No default | The value reported on change. |
labelRequired | string | No default | The visible label, or the aria-label with hideLabel. |
icon | ReactNode | No default | A 14px icon before the label. |
hideLabel | boolean | false | Shows 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.