macbetter

Navigation

Tab Bar

Switch between the top-level sections of an iPhone or iPad app.

import { Artwork } from "macbetter/artwork"
import { DeviceFrame } from "macbetter/device-frame"
import { List, ListRow, ListRowAccessory, ListRowSubtitle, ListRowTitle, ListSection } from "macbetter/list"
import { NavigationBar, NavigationStack } from "macbetter/navigation-bar"
import { TabBar, TabBarItem, TabBarList, TabBarPanel } from "macbetter/tab-bar"
import { HouseIcon, LayoutGridIcon, LibraryIcon, RadioIcon, SearchIcon } from "lucide-react"

const albums = [
  { title: "Golden Hour Tapes", artist: "The Saltmarsh", variant: "bloom", palette: "sunset" },
  { title: "Evergreen", artist: "Mara Quill", variant: "strata", palette: "forest" },
  { title: "Low Tide", artist: "Harbor Lights", variant: "ribbon", palette: "ocean" },
  { title: "Kiln", artist: "Oda Brenner", variant: "rays", palette: "ember" },
  { title: "Night Garden", artist: "Juniper & Ash", variant: "aurora", palette: "berry" },
  { title: "Fieldwork", artist: "Calloway", variant: "halftone", palette: "citrus" },
  { title: "Paper Moons", artist: "Lio Hart", variant: "orbit", palette: "glacier" },
  { title: "Overland", artist: "Tamsin Vale", variant: "topo", palette: "dusk" },
  { title: "Bright Static", artist: "Neon Orchard", variant: "prism", palette: "sunset" },
  { title: "Quiet Hours", artist: "Elm Street Trio", variant: "glyph", palette: "ocean" },
] as const

const tabs = [
  { value: "home", label: "Home", icon: <HouseIcon /> },
  { value: "new", label: "New", icon: <LayoutGridIcon /> },
  { value: "radio", label: "Radio", icon: <RadioIcon /> },
  { value: "library", label: "Library", icon: <LibraryIcon /> },
  { value: "search", label: "Search", icon: <SearchIcon /> },
]

export default function Example() {
  return (
    <DeviceFrame scale={0.78}>
      <TabBar defaultValue="library">
        {tabs.map((tab) => (
          <TabBarPanel key={tab.value} value={tab.value}>
            <NavigationStack>
              <NavigationBar title={tab.label} largeTitle />
              <List variant="plain">
                <ListSection>
                  {albums.map((album) => (
                    <ListRow key={album.title} href={`#${album.title}`}>
                      <Artwork
                        seed={album.title}
                        variant={album.variant}
                        palette={album.palette}
                        size={52}
                        radius={8}
                        style={{ gridColumn: 1, gridRow: "1 / -1", marginInlineEnd: 14 }}
                      />
                      <ListRowTitle>{album.title}</ListRowTitle>
                      <ListRowSubtitle>{album.artist}</ListRowSubtitle>
                      <ListRowAccessory type="disclosure" />
                    </ListRow>
                  ))}
                </ListSection>
              </List>
            </NavigationStack>
          </TabBarPanel>
        ))}
        <TabBarList aria-label="Music">
          {tabs.map((tab) => (
            <TabBarItem key={tab.value} value={tab.value} icon={tab.icon}>
              {tab.label}
            </TabBarItem>
          ))}
        </TabBarList>
      </TabBar>
    </DeviceFrame>
  )
}

When to use it

Use a tab bar for an app's two to five top-level sections, like Home, Library and Search. Each tab keeps its own place: switching away and back returns to the screen you left.

  • Use tabs to navigate, never to run an action. A button that composes or adds belongs in a navigation bar or toolbar.
  • Label every tab with a short noun, and pair it with a symbol people know.
  • Keep the tab bar visible on every screen of the hierarchy below it. Hide it only for a modal task.
  • On macOS, use a sidebar or tabs instead.

Usage

import { TabBar, TabBarItem, TabBarList, TabBarPanel } from "macbetter/tab-bar"

TabBar holds a TabBarPanel per section and the TabBarList of items. Give each item and its panel the same value, and name the list with aria-label. The bar floats over the panels; it sets --mb-content-inset-bottom so a navigation stack inside a panel scrolls its last row clear of it.

Floating

The default is the floating glass capsule of iOS 26. The selected tab takes the accent color, and a glass pill slides to it, with a little give under the finger.

Classic

variant="classic" is the full-width translucent bar of earlier iOS versions, with a hairline along its top. Use it when the app's design follows iOS 18.

iOS only

World Clock

Today, +0HRSCupertino9:41
Today, +3HRSNew York12:41
Today, +8HRSLisbon17:41
Today, +10HRSNairobi19:41
Tomorrow, +16HRSTokyo1:41
Tomorrow, +20HRSAuckland5:41
import { DeviceFrame } from "macbetter/device-frame"
import { List, ListRow, ListRowSubtitle, ListRowTitle, ListRowValue, ListSection } from "macbetter/list"
import { NavigationBar, NavigationStack } from "macbetter/navigation-bar"
import { TabBar, TabBarItem, TabBarList, TabBarPanel } from "macbetter/tab-bar"
import { AlarmClockIcon, GlobeIcon, HourglassIcon, TimerIcon } from "lucide-react"

const cities = [
  { city: "Cupertino", offset: "Today, +0HRS", time: "9:41" },
  { city: "New York", offset: "Today, +3HRS", time: "12:41" },
  { city: "Lisbon", offset: "Today, +8HRS", time: "17:41" },
  { city: "Nairobi", offset: "Today, +10HRS", time: "19:41" },
  { city: "Tokyo", offset: "Tomorrow, +16HRS", time: "1:41" },
  { city: "Auckland", offset: "Tomorrow, +20HRS", time: "5:41" },
]

const tabs = [
  { value: "world", label: "World Clock", icon: <GlobeIcon /> },
  { value: "alarms", label: "Alarms", icon: <AlarmClockIcon /> },
  { value: "stopwatch", label: "Stopwatch", icon: <TimerIcon /> },
  { value: "timers", label: "Timers", icon: <HourglassIcon /> },
]

export default function Example() {
  return (
    <DeviceFrame scale={0.78}>
      <TabBar defaultValue="world">
        {tabs.map((tab) => (
          <TabBarPanel key={tab.value} value={tab.value}>
            <NavigationStack>
              <NavigationBar title={tab.label} largeTitle />
              <List variant="plain">
                <ListSection>
                  {cities.map((item) => (
                    <ListRow key={item.city}>
                      <ListRowSubtitle style={{ gridRow: 2 }}>{item.offset}</ListRowSubtitle>
                      <ListRowTitle style={{ gridRow: 3, fontSize: 24, lineHeight: "30px" }}>{item.city}</ListRowTitle>
                      <ListRowValue style={{ fontSize: 44, lineHeight: "52px", fontWeight: 300, color: "var(--mb-label)" }}>
                        {item.time}
                      </ListRowValue>
                    </ListRow>
                  ))}
                </ListSection>
              </List>
            </NavigationStack>
          </TabBarPanel>
        ))}
        <TabBarList variant="classic" aria-label="Clock">
          {tabs.map((tab) => (
            <TabBarItem key={tab.value} value={tab.value} icon={tab.icon}>
              {tab.label}
            </TabBarItem>
          ))}
        </TabBarList>
      </TabBar>
    </DeviceFrame>
  )
}

iPad

On iPadOS, the floating tab bar moves to the top of the screen as a compact capsule of labels, centered in the navigation bar's row. The capsule takes the bar's center, so give those screens a large title: an inline title steps out of sight under a top tab bar (it stays the heading for screen readers).

iPadOS only

Home

Top Picks for You

Morning Coffee
Deep Focus
Late Night Jazz
Road Trip
Rainy Day
Indie Mix
Workout Hits
Sunday Soul
Chill Electronic
Acoustic Covers

Recently Played

Road Trip
Rainy Day
Indie Mix
Workout Hits
Sunday Soul
Chill Electronic
Acoustic Covers
Morning Coffee
Deep Focus
Late Night Jazz

Made for You

Workout Hits
Sunday Soul
Chill Electronic
Acoustic Covers
Morning Coffee
Deep Focus
Late Night Jazz
Road Trip
Rainy Day
Indie Mix
import { Artwork, type ArtworkVariant } from "macbetter/artwork"
import { DeviceFrame } from "macbetter/device-frame"
import { NavigationBar, NavigationStack } from "macbetter/navigation-bar"
import { TabBar, TabBarItem, TabBarList, TabBarPanel } from "macbetter/tab-bar"
import { HouseIcon, LayoutGridIcon, LibraryIcon, RadioIcon, SearchIcon } from "lucide-react"

const tabs = [
  { value: "home", label: "Home", icon: <HouseIcon /> },
  { value: "new", label: "New", icon: <LayoutGridIcon /> },
  { value: "radio", label: "Radio", icon: <RadioIcon /> },
  { value: "library", label: "Library", icon: <LibraryIcon /> },
  { value: "search", label: "Search", icon: <SearchIcon /> },
]

const palettes = ["sunset", "citrus", "forest", "glacier", "ocean", "dusk", "berry", "ember", "candy", "sunset"] as const

const shelfStyles: ArtworkVariant[] = ["aurora", "mosaic", "bloom"]

const shelves = ["Top Picks for You", "Recently Played", "Made for You"]

const stations = [
  "Morning Coffee",
  "Deep Focus",
  "Late Night Jazz",
  "Road Trip",
  "Rainy Day",
  "Indie Mix",
  "Workout Hits",
  "Sunday Soul",
  "Chill Electronic",
  "Acoustic Covers",
]

export default function Example() {
  return (
    <DeviceFrame device="ipad" scale={0.56}>
      <TabBar defaultValue="home">
        {tabs.map((tab) => (
          <TabBarPanel key={tab.value} value={tab.value}>
            <NavigationStack>
              <NavigationBar title={tab.label} largeTitle />
              {shelves.map((shelf, shelfIndex) => (
                <section key={shelf} style={{ padding: "4px 20px 24px" }}>
                  <h2 className="mb-text-title-3" style={{ margin: "0 0 12px" }}>{shelf}</h2>
                  <div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 20 }}>
                    {palettes.map((palette, index) => {
                      const station = stations[(index + shelfIndex * 3) % stations.length]
                      return (
                        <div key={station}>
                          <Artwork seed={station} variant={shelfStyles[shelfIndex]} palette={palette} radius={12} />
                          <div className="mb-text-subheadline" style={{ marginTop: 8 }}>
                            {station}
                          </div>
                        </div>
                      )
                    })}
                  </div>
                </section>
              ))}
            </NavigationStack>
          </TabBarPanel>
        ))}
        <TabBarList aria-label="Music">
          {tabs.map((tab) => (
            <TabBarItem key={tab.value} value={tab.value} icon={tab.icon}>
              {tab.label}
            </TabBarItem>
          ))}
        </TabBarList>
      </TabBar>
    </DeviceFrame>
  )
}

Platform behavior

  • iOS. The floating bar is 62 points tall, inset 20 points from the screen's sides and sitting just above the home indicator. Labels are 10-point semibold under 24-point symbols. The classic bar is 49 points plus the home indicator's safe area, with 10-point medium labels.
  • iPadOS. The floating bar is a 44-point capsule at the top, labels only, centered in the 50-point navigation bar row. The classic bar stays at the bottom, as it was before iPadOS 18.
  • Safe area. The bar reads --mb-safe-bottom (and --mb-safe-top on iPad), which default to the device's env(safe-area-inset-*) and which a device frame sets for its screen.
  • macOS. Mac apps don't use tab bars. Use a sidebar for an app's sections.
  • The tab bar has no hover state on touch. With a pointer, unselected tabs dim slightly on hover.

Accessibility

TabBar is built on ui-sdk Tabs, so it follows the WAI-ARIA tabs pattern:

  • The list is a tablist, each item a tab that controls its panel, and each panel is labelled by its tab.
  • Tab moves focus into the bar at the selected item. Left and Right Arrow move between items and select them; Home and End go to the first and last.
  • Icons sit in TabsTabIcon, out of the tab's name, so the label is what's announced.
  • Under prefers-reduced-transparency the glass turns opaque, and under prefers-reduced-motion the pill moves without bouncing.
  • In right-to-left layouts the items run right to left, so the first tab sits at the right edge, as on iOS.

API

TabBar

ui-sdk Tabs: value, defaultValue, onValueChange, activation and the rest.

TabBarList

PropTypeDefaultDescription
variant"floating" | "classic""floating"The iOS 26 glass capsule, or the full-width translucent bar.

TabBarItem

PropTypeDefaultDescription
valuestring—Pairs the item with its panel.
iconReactNode—The symbol above the label. Decorative.
disabledbooleanfalseKeeps the item focusable but unselectable.

TabBarPanel

ui-sdk TabsPanel: value and keepMounted.

Data attributes and variables

Attribute or variableOnMeaning
data-variant.mb-tab-bar-listfloating or classic.
data-selected.mb-tab-bar-item, .mb-tab-bar-panelThe selected tab and its panel.
--mb-tab-bar-height.mb-tab-barThe bar's height, without the safe area.
--mb-tab-bar-margin.mb-tab-barThe floating bar's distance from the bottom edge.
--mb-content-inset-bottom.mb-tab-barThe space content leaves for the bar. A NavigationStack inside a panel pads its end by it.
--mb-safe-top, --mb-safe-bottominheritedThe device's safe area, from env() or a device frame.
--active-tab-left, --active-tab-width.mb-tab-bar-indicatorThe selected tab's box, from ui-sdk.