macbetter

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 sm and lg). 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-label or aria-labelledby, and every icon-only item with aria-label.
  • The sliding pill is presentational; screen readers hear the checked radio.

API

SegmentedControl

PropTypeDefaultDescription
size"sm" | "md" | "lg""md"Height and type size.
blockbooleanfalseFills the container with equal-width segments.
valuestring | null—The checked item's value. Pass it to control the control.
defaultValuestring | null—The checked item at first, when uncontrolled.
onValueChange(value, details) => void—Runs when the choice wants to change.
disabledbooleanfalseDisables every item.
orientation"horizontal" | "vertical""horizontal"Which way the items run.
namestring—The name the value submits under.

SegmentedControlItem

PropTypeDefaultDescription
valuestring—What the value becomes when this item is checked.
disabledbooleanfalseSkips the item.

Data attributes

AttributeOnValues
data-sizeSegmentedControlsm, md, lg
data-blockSegmentedControlPresent when block is set
data-orientationSegmentedControl, indicatorhorizontal, vertical
data-checked / data-uncheckedSegmentedControlItemThe item's state
data-disabledBothPresent when disabled

CSS variables

VariableDefaultWhat it sets
--mb-segmented-height28px on macOS, 32pt on touch (per size)Height of the control
--mb-segmented-padding--mb-control-padding on macOS, 14px on touchInline padding of each segment
--mb-segmented-icon-size--mb-icon-size on macOS, 18px on touchSize of icons in segments
--mb-segmented-inset2pxSpace between the track and the pill
--mb-segmented-radius--mb-control-radiusCorner radius of the track