Buttons and controls
Segmented Control
Choose one of a few closely related options, or switch between views.
import { SegmentedControl, SegmentedControlItem } from "macbetter/segmented-control"
export default function Example() {
return (
<SegmentedControl aria-label="Calendar view" defaultValue="week">
<SegmentedControlItem value="day">Day</SegmentedControlItem>
<SegmentedControlItem value="week">Week</SegmentedControlItem>
<SegmentedControlItem value="month">Month</SegmentedControlItem>
<SegmentedControlItem value="year">Year</SegmentedControlItem>
</SegmentedControl>
)
}When to use it
Use a segmented control to pick one option from two to five, when every option fits on screen at once: a calendar's Day, Week, Month and Year, or Finder's view modes. The choice should change what's shown right away.
- Keep labels short and of similar length, all text or all icons. Mixing the two in one control is hard to scan.
- For more than five options, use a Pop-Up Button. For settings in a form, use Radio buttons. For navigating between unrelated sections, use Tabs.
Usage
import { SegmentedControl, SegmentedControlItem } from "macbetter/segmented-control"With icons
Give an icon-only item an aria-label. An icon beside text is hidden from screen readers.
import { SegmentedControl, SegmentedControlItem } from "macbetter/segmented-control"
import { Columns3Icon, GalleryVerticalIcon, LayoutGridIcon, ListIcon } from "lucide-react"
export default function Example() {
return (
<>
<SegmentedControl aria-label="View as" defaultValue="icons">
<SegmentedControlItem value="icons" aria-label="Icons">
<LayoutGridIcon aria-hidden="true" />
</SegmentedControlItem>
<SegmentedControlItem value="list" aria-label="List">
<ListIcon aria-hidden="true" />
</SegmentedControlItem>
<SegmentedControlItem value="columns" aria-label="Columns">
<Columns3Icon aria-hidden="true" />
</SegmentedControlItem>
<SegmentedControlItem value="gallery" aria-label="Gallery">
<GalleryVerticalIcon aria-hidden="true" />
</SegmentedControlItem>
</SegmentedControl>
<SegmentedControl aria-label="Layout" defaultValue="grid">
<SegmentedControlItem value="grid">
<LayoutGridIcon aria-hidden="true" />
Grid
</SegmentedControlItem>
<SegmentedControlItem value="list">
<ListIcon aria-hidden="true" />
List
</SegmentedControlItem>
</SegmentedControl>
</>
)
}Block
block stretches the control across its container and makes every segment the same width, like the control under an iOS navigation bar.
import { SegmentedControl, SegmentedControlItem } from "macbetter/segmented-control"
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 420 }}>
<SegmentedControl aria-label="Map style" defaultValue="explore" block>
<SegmentedControlItem value="explore">Explore</SegmentedControlItem>
<SegmentedControlItem value="driving">Driving</SegmentedControlItem>
<SegmentedControlItem value="transit">Transit</SegmentedControlItem>
<SegmentedControlItem value="satellite">Satellite</SegmentedControlItem>
</SegmentedControl>
</div>
)
}Sizes
size is sm, md or lg, from the platform's control heights.
import { SegmentedControl, SegmentedControlItem } from "macbetter/segmented-control"
const sizes = ["sm", "md", "lg"] as const
export default function Example() {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 16 }}>
{sizes.map((size) => (
<SegmentedControl key={size} size={size} aria-label={`Sort order, ${size}`} defaultValue="date">
<SegmentedControlItem value="name">Name</SegmentedControlItem>
<SegmentedControlItem value="date">Date</SegmentedControlItem>
<SegmentedControlItem value="size">Size</SegmentedControlItem>
</SegmentedControl>
))}
</div>
)
}Controlled
Pass value and onValueChange to keep the choice in your state. The value is null only when nothing is checked.
This week: 38,240 steps
"use client"
import { SegmentedControl, SegmentedControlItem } from "macbetter/segmented-control"
import { useState } from "react"
const ranges = {
day: "Today: 4,812 steps",
week: "This week: 38,240 steps",
month: "This month: 161,905 steps",
}
type Range = keyof typeof ranges
export default function Example() {
const [range, setRange] = useState<Range>("week")
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 14 }}>
<SegmentedControl
aria-label="Time range"
value={range}
onValueChange={(value) => value && setRange(value as Range)}
>
<SegmentedControlItem value="day">Day</SegmentedControlItem>
<SegmentedControlItem value="week">Week</SegmentedControlItem>
<SegmentedControlItem value="month">Month</SegmentedControlItem>
</SegmentedControl>
<p style={{ margin: 0, color: "var(--mb-label-secondary)" }}>{ranges[range]}</p>
</div>
)
}Platform behavior
- macOS. A recessed rounded rectangle with a raised, white pill behind the choice (a lighter fill in dark mode). Hovering an unchosen segment shows a faint fill.
- iPadOS and iOS. A capsule with a capsule pill, sized like UIKit's: 32 points tall with 13-point text (28 and 40 for
smandlg). Each segment still answers touches across a 44-point height. The pill gives a little under the finger before it slides. - Every platform. The pill slides to the new choice on a snappy spring. The chosen label turns semibold, so the selection reads without color.
- Increase Contrast. The track and the pill get 3:1 edges, as they do in macOS and iOS with Increase Contrast on.
Accessibility
- A segmented control is a radio group: one tab stop, and the arrow keys move and check. Space checks the focused item; Enter submits its form.
- Name the control with
aria-labeloraria-labelledby, and every icon-only item witharia-label. - The sliding pill is presentational; screen readers hear the checked radio.
API
SegmentedControl
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "md" | Height and type size. |
block | boolean | false | Fills the container with equal-width segments. |
value | string | null | — | The checked item's value. Pass it to control the control. |
defaultValue | string | null | — | The checked item at first, when uncontrolled. |
onValueChange | (value, details) => void | — | Runs when the choice wants to change. |
disabled | boolean | false | Disables every item. |
orientation | "horizontal" | "vertical" | "horizontal" | Which way the items run. |
name | string | — | The name the value submits under. |
SegmentedControlItem
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | What the value becomes when this item is checked. |
disabled | boolean | false | Skips the item. |
Data attributes
| Attribute | On | Values |
|---|---|---|
data-size | SegmentedControl | sm, md, lg |
data-block | SegmentedControl | Present when block is set |
data-orientation | SegmentedControl, indicator | horizontal, vertical |
data-checked / data-unchecked | SegmentedControlItem | The item's state |
data-disabled | Both | Present when disabled |
CSS variables
| Variable | Default | What it sets |
|---|---|---|
--mb-segmented-height | 28px on macOS, 32pt on touch (per size) | Height of the control |
--mb-segmented-padding | --mb-control-padding on macOS, 14px on touch | Inline padding of each segment |
--mb-segmented-icon-size | --mb-icon-size on macOS, 18px on touch | Size of icons in segments |
--mb-segmented-inset | 2px | Space between the track and the pill |
--mb-segmented-radius | --mb-control-radius | Corner radius of the track |