Segmented control
One choice out of two to five short ones, drawn as one track. It is a radio group: the arrow keys select as they move, and reverse in RTL.
client - size not measured yet
Segmented control
client - size not measured yetOne choice out of two to five short ones, drawn as one track. It is a radio group: the arrow keys select as they move, and reverse in RTL.
Try it
size
fullWidth
invalid
The code
segmented-control.tsx
import { SegmentedControlItem, SegmentedControlRoot } from 'topclassui/segmented-control'
<SegmentedControlRoot aria-label="View" defaultValue="board">
<SegmentedControlItem value="board">Board</SegmentedControlItem>
<SegmentedControlItem value="table">Table</SegmentedControlItem>
<SegmentedControlItem value="calendar">Calendar</SegmentedControlItem>
</SegmentedControlRoot>The arrow keys move and select at once; under an RTL TopClassProvider, ArrowRight goes to the segment before.
Every state
4 statesImport from the package
topclassui/segmented-control
import { SegmentedControlRoot, SegmentedControlItem } from 'topclassui/segmented-control'Install once:npm i topclassui@beta zod
Examples
- For one choice out of two to five short ones: a view, a mode, a range. One segment is always selected; for longer choices, a RadioGroup reads better.
- Two parts,
SegmentedControlRootandSegmentedControlItem, and it runs in the browser. A server page passesdefaultValuealone; a controlledvaluebelongs in a client file. - The track needs a name and a selected segment: pass
aria-labeloraria-labelledby, andvalueordefaultValue. The types refuse a track without them. invalidsetsaria-invalidon the track, whose line turns red and thickens.fullWidthfills the parent and shares it equally, for a few short segments.- When the segments do not fit, the track scrolls sideways inside itself and the page does not; a label longer than its segment ends in an ellipsis.
iconon a segment is drawn before its label. It is decoration: the label names the segment.
basic
basic.tsx
import { SegmentedControlItem, SegmentedControlRoot } from 'topclassui/segmented-control'
/** A view switch: no name and no form, one segment always selected. The track is named by aria-label, and each
* segment by its own text. */
export default function Demo() {
return (
<SegmentedControlRoot aria-label="View" defaultValue="board">
<SegmentedControlItem value="board">Board</SegmentedControlItem>
<SegmentedControlItem value="table">Table</SegmentedControlItem>
<SegmentedControlItem value="calendar">Calendar</SegmentedControlItem>
</SegmentedControlRoot>
)
}
with-icons
with-icons.tsx
import { monitor } from 'topclassui/icons/monitor'
import { moon } from 'topclassui/icons/moon'
import { sun } from 'topclassui/icons/sun'
import { Svg } from 'topclassui/svg'
import { SegmentedControlItem, SegmentedControlRoot } from 'topclassui/segmented-control'
/** The theme switcher of the docs: an icon before each label. The icon is decoration; the label names the segment. */
export default function Demo() {
return (
<SegmentedControlRoot aria-label="Theme" defaultValue="light">
<SegmentedControlItem value="light" icon={<Svg icon={sun} />}>Light</SegmentedControlItem>
<SegmentedControlItem value="dark" icon={<Svg icon={moon} />}>Dark</SegmentedControlItem>
<SegmentedControlItem value="system" icon={<Svg icon={monitor} />}>System</SegmentedControlItem>
</SegmentedControlRoot>
)
}
sizes
sizes.tsx
import { SegmentedControlItem, SegmentedControlRoot } from 'topclassui/segmented-control'
/** The five sizes, one track each, the first segment selected. A segment is never under 24 px, 44 under a finger. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-4">
<SegmentedControlRoot size="xs" aria-label="Extra small" defaultValue="on">
<SegmentedControlItem value="on">Extra small</SegmentedControlItem>
<SegmentedControlItem value="off">Off</SegmentedControlItem>
</SegmentedControlRoot>
<SegmentedControlRoot size="sm" aria-label="Small" defaultValue="on">
<SegmentedControlItem value="on">Small</SegmentedControlItem>
<SegmentedControlItem value="off">Off</SegmentedControlItem>
</SegmentedControlRoot>
<SegmentedControlRoot size="md" aria-label="Medium" defaultValue="on">
<SegmentedControlItem value="on">Medium</SegmentedControlItem>
<SegmentedControlItem value="off">Off</SegmentedControlItem>
</SegmentedControlRoot>
<SegmentedControlRoot size="lg" aria-label="Large" defaultValue="on">
<SegmentedControlItem value="on">Large</SegmentedControlItem>
<SegmentedControlItem value="off">Off</SegmentedControlItem>
</SegmentedControlRoot>
<SegmentedControlRoot size="xl" aria-label="Extra large" defaultValue="on">
<SegmentedControlItem value="on">Extra large</SegmentedControlItem>
<SegmentedControlItem value="off">Off</SegmentedControlItem>
</SegmentedControlRoot>
</div>
)
}
full-width
Range
full-width.tsx
import { SegmentedControlItem, SegmentedControlRoot } from 'topclassui/segmented-control'
/** fullWidth: the track fills its parent and the segments share it equally. For a few short segments. */
export default function Demo() {
return (
<div className="grid max-w-sm gap-2">
<span id="segmented-control-full-label" className="text-sm text-body">Range</span>
<SegmentedControlRoot aria-labelledby="segmented-control-full-label" fullWidth defaultValue="week">
<SegmentedControlItem value="day">Day</SegmentedControlItem>
<SegmentedControlItem value="week">Week</SegmentedControlItem>
<SegmentedControlItem value="month">Month</SegmentedControlItem>
</SegmentedControlRoot>
</div>
)
}
form
form.tsx
import { Button } from 'topclassui/button'
import { SegmentedControlItem, SegmentedControlRoot } from 'topclassui/segmented-control'
/** With a name the track is a form control: it sends view=table, before any script runs and after. */
export default function Demo() {
return (
<form className="grid justify-items-start gap-3">
<span id="segmented-control-form-label" className="text-sm text-body">Default view</span>
<SegmentedControlRoot aria-labelledby="segmented-control-form-label" name="view" defaultValue="table">
<SegmentedControlItem value="board">Board</SegmentedControlItem>
<SegmentedControlItem value="table">Table</SegmentedControlItem>
<SegmentedControlItem value="calendar">Calendar</SegmentedControlItem>
</SegmentedControlRoot>
<Button type="submit">Save</Button>
</form>
)
}
many
many.tsx
import { SegmentedControlItem, SegmentedControlRoot } from 'topclassui/segmented-control'
const SECTIONS = ['Overview', 'Activity log', 'Team members', 'Billing and invoices', 'Integrations', 'Security settings', 'Notifications']
/** Seven long segments, more than a phone is wide: the track scrolls sideways inside itself and the page does not.
* An arrow key brings the next segment into view. */
export default function Demo() {
return (
<SegmentedControlRoot aria-label="Workspace" defaultValue="Overview">
{SECTIONS.map((section) => <SegmentedControlItem key={section} value={section}>{section}</SegmentedControlItem>)}
</SegmentedControlRoot>
)
}
long-content
long-content.tsx
import { SegmentedControlItem, SegmentedControlRoot } from 'topclassui/segmented-control'
/** A segment holds one line: a label wider than the track ends in an ellipsis, and the full text stays its name. */
export default function Demo() {
return (
<SegmentedControlRoot aria-label="Files" defaultValue="recent">
<SegmentedControlItem value="recent">Recently opened by anyone in this workspace</SegmentedControlItem>
<SegmentedControlItem value="starred">Starred</SegmentedControlItem>
<SegmentedControlItem value="shared">Shared with me</SegmentedControlItem>
</SegmentedControlRoot>
)
}
In Arabic, right to left
طريقة العرض
rtl.tsx
import { TopClassProvider } from 'topclassui/provider'
import { SegmentedControlItem, SegmentedControlRoot } from 'topclassui/segmented-control'
/** Arabic, inside TopClassProvider as the root layout would mount it. The first segment is at the right, and
* ArrowRight goes to the segment before. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar" className="grid justify-items-start gap-2">
<span id="segmented-control-rtl-label" className="text-sm text-body">طريقة العرض</span>
<SegmentedControlRoot aria-labelledby="segmented-control-rtl-label" name="view" defaultValue="table">
<SegmentedControlItem value="board">لوحة</SegmentedControlItem>
<SegmentedControlItem value="table">جدول</SegmentedControlItem>
<SegmentedControlItem value="calendar">تقويم</SegmentedControlItem>
</SegmentedControlRoot>
</div>
</TopClassProvider>
)
}
Props
SegmentedControlRoot
SegmentedControlItem
Keyboard and accessibility
- The track is a
radiogroup; each segment is a native<button>withrole="radio", named by its own text. The arrows skip a disabled segment and wrap at both ends; Enter, Home and End do nothing. - Before the page hydrates, the selected segment is already the one tab stop, so Tab lands on it from the first paint.
- Each segment is at least 24 px tall, 44 under a touch pointer. The focus ring is drawn inside the focused segment, and under forced colours the selected one takes the system highlight.
Right to left
The first segment sits at the right, and the arrow keys reverse with the direction TopClassProvider passes from your layout.
Server and client
A client component: it renders on the server and hydrates in the browser.
Files: index.tsx, client.tsx.
On the chip: client - size not measured yet.