macbetter

Navigation

Tabs

Switch between related views that share the same space.

Show scroll bars:
When scrolling
Click in the scroll bar to:
Jump to the next page
Default web browser:
Safari
import { Tabs, TabsList, TabsPanel, TabsTab } from "macbetter/tabs"

const rows = {
  general: [
    ["Show scroll bars", "When scrolling"],
    ["Click in the scroll bar to", "Jump to the next page"],
    ["Default web browser", "Safari"],
  ],
  sharing: [
    ["Computer name", "Kyle’s MacBook Pro"],
    ["File Sharing", "On"],
    ["Screen Sharing", "Off"],
  ],
  network: [
    ["Wi-Fi", "Connected to macbetter"],
    ["IP address", "192.168.1.24"],
    ["Firewall", "Active"],
  ],
} as const

export default function Example() {
  return (
    <Tabs defaultValue="general" style={{ width: "100%", maxWidth: 460 }}>
      <TabsList aria-label="Settings">
        <TabsTab value="general">General</TabsTab>
        <TabsTab value="sharing">Sharing</TabsTab>
        <TabsTab value="network">Network</TabsTab>
      </TabsList>
      {(Object.keys(rows) as (keyof typeof rows)[]).map((key) => (
        <TabsPanel key={key} value={key}>
          <dl style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "10px 16px", margin: 0 }}>
            {rows[key].map(([label, value]) => (
              <div key={label} style={{ display: "contents" }}>
                <dt className="mb-label-secondary" style={{ textAlign: "end" }}>{label}:</dt>
                <dd style={{ margin: 0 }}>{value}</dd>
              </div>
            ))}
          </dl>
        </TabsPanel>
      ))}
    </Tabs>
  )
}

When to use it

Use a tab view to group a few related panes of controls, like the sections of a settings window, where each pane makes sense on its own. Keep the labels to a word or two and use nouns, not verbs.

Tabs are for content within a window. To switch between the top-level areas of an iPhone or iPad app, use a tab bar. To change how one set of content is shown, like a list or a grid, use a segmented control in the toolbar.

Keep the number of tabs small, usually six or fewer. If people need more, a sidebar or a pop-up button reads better.

Usage

import { Tabs, TabsList, TabsPanel, TabsTab } from "macbetter/tabs"

Tab view

The default bordered variant is the macOS tab view: a segmented strip sits on the top edge of a recessed, framed box. The selected segment slides to the tab you choose.

Show scroll bars:
When scrolling
Click in the scroll bar to:
Jump to the next page
Default web browser:
Safari
import { Tabs, TabsList, TabsPanel, TabsTab } from "macbetter/tabs"

const rows = {
  general: [
    ["Show scroll bars", "When scrolling"],
    ["Click in the scroll bar to", "Jump to the next page"],
    ["Default web browser", "Safari"],
  ],
  sharing: [
    ["Computer name", "Kyle’s MacBook Pro"],
    ["File Sharing", "On"],
    ["Screen Sharing", "Off"],
  ],
  network: [
    ["Wi-Fi", "Connected to macbetter"],
    ["IP address", "192.168.1.24"],
    ["Firewall", "Active"],
  ],
} as const

export default function Example() {
  return (
    <Tabs defaultValue="general" style={{ width: "100%", maxWidth: 460 }}>
      <TabsList aria-label="Settings">
        <TabsTab value="general">General</TabsTab>
        <TabsTab value="sharing">Sharing</TabsTab>
        <TabsTab value="network">Network</TabsTab>
      </TabsList>
      {(Object.keys(rows) as (keyof typeof rows)[]).map((key) => (
        <TabsPanel key={key} value={key}>
          <dl style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "10px 16px", margin: 0 }}>
            {rows[key].map(([label, value]) => (
              <div key={label} style={{ display: "contents" }}>
                <dt className="mb-label-secondary" style={{ textAlign: "end" }}>{label}:</dt>
                <dd style={{ margin: 0 }}>{value}</dd>
              </div>
            ))}
          </dl>
        </TabsPanel>
      ))}
    </Tabs>
  )
}

Plain

variant="plain" drops the frame and the track. A soft capsule slides behind the selected label, with no underline, for tabs at the top of a page or a popover. Put decorative icons in TabsTabIcon.

Inès Moreau called 12 minutes ago. Kofi Mensah, yesterday.
import { Tabs, TabsList, TabsPanel, TabsTab, TabsTabIcon } from "macbetter/tabs"
import { ClockIcon, StarIcon, UsersIcon } from "lucide-react"

export default function Example() {
  return (
    <Tabs variant="plain" defaultValue="recents" style={{ width: "100%", maxWidth: 460 }}>
      <TabsList aria-label="Contacts">
        <TabsTab value="recents">
          <TabsTabIcon>
            <ClockIcon />
          </TabsTabIcon>
          Recents
        </TabsTab>
        <TabsTab value="favorites">
          <TabsTabIcon>
            <StarIcon />
          </TabsTabIcon>
          Favorites
        </TabsTab>
        <TabsTab value="groups">
          <TabsTabIcon>
            <UsersIcon />
          </TabsTabIcon>
          Groups
        </TabsTab>
      </TabsList>
      <TabsPanel value="recents" className="mb-label-secondary">
        Inès Moreau called 12 minutes ago. Kofi Mensah, yesterday.
      </TabsPanel>
      <TabsPanel value="favorites" className="mb-label-secondary">
        Hollis Grant and Wren Takahashi are in your favorites.
      </TabsPanel>
      <TabsPanel value="groups" className="mb-label-secondary">
        Design, Family and Climbing Club.
      </TabsPanel>
    </Tabs>
  )
}

Platform behavior

  • macOS: a compact strip, 24px tall, centered on the panel's top edge, with hover feedback under a pointer.
  • iPadOS and iOS: the strip stretches to the width of the content as a segmented control, 34pt tall, and the panel becomes an inset grouped surface below it. The selected segment gives a little under the finger.
  • A vertical tab view (orientation="vertical") puts the strip beside its panel on every platform.

Accessibility

  • The list is a tablist; each tab controls its panel, and each panel is labelled by its tab. Name the list with aria-label.
  • Tab moves focus into the list at the selected tab and out again on the next Tab. Left and Right move between tabs and select them, Home and End go to the ends, and right-to-left text swaps the arrows.
  • Icons in TabsTabIcon stay out of the tab's name. An icon-only tab still needs aria-label.

API

Tabs

PropTypeDefaultDescription
variant"bordered" | "plain""bordered"The framed macOS tab view, or tabs with no frame.
defaultValuestring—The tab selected at first. Defaults to the first enabled tab.
valuestring | null—The selected tab, to control it.
onValueChange(value, details) => void—Runs when the selection wants to change.
orientation"horizontal" | "vertical""horizontal"Which arrow keys move between tabs, and where the strip sits.
activation"automatic" | "manual""automatic"Whether arrow keys select a tab or only focus it.

TabsList

Renders the sliding indicator for you. Takes the props of a div; name it with aria-label.

TabsTab and TabsPanel

PropTypeDefaultDescription
valuestring—Pairs a tab with the panel of the same value.
disabledboolean—On a tab: keeps it focusable but unselectable.
keepMountedboolean—On a panel: keeps it in the page while hidden, to preserve state.

TabsTabIcon

PropTypeDefaultDescription
mirrorboolean—Flips a directional icon in right-to-left text.

Data attributes and CSS variables

Attribute or variableOnMeaning
data-variant.mb-tabsbordered or plain
data-selected.mb-tabs-tab, .mb-tabs-panelThe selected tab and its panel
data-orientationevery parthorizontal or vertical
data-disabled.mb-tabs-tabThe tab can't be selected
--mb-tabs-heightinheritedHeight of the strip: 24px on macOS, 34pt on touch
--mb-tabs-paddinginheritedHorizontal padding inside each tab
--active-tab-left, --active-tab-width….mb-tabs-indicatorThe selected tab's box, from ui-sdk