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.
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).
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-topon iPad), which default to the device'senv(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 atabthat 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-transparencythe glass turns opaque, and underprefers-reduced-motionthe 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
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "floating" | "classic" | "floating" | The iOS 26 glass capsule, or the full-width translucent bar. |
TabBarItem
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Pairs the item with its panel. |
icon | ReactNode | — | The symbol above the label. Decorative. |
disabled | boolean | false | Keeps the item focusable but unselectable. |
TabBarPanel
ui-sdk TabsPanel: value and keepMounted.
Data attributes and variables
| Attribute or variable | On | Meaning |
|---|---|---|
data-variant | .mb-tab-bar-list | floating or classic. |
data-selected | .mb-tab-bar-item, .mb-tab-bar-panel | The selected tab and its panel. |
--mb-tab-bar-height | .mb-tab-bar | The bar's height, without the safe area. |
--mb-tab-bar-margin | .mb-tab-bar | The floating bar's distance from the bottom edge. |
--mb-content-inset-bottom | .mb-tab-bar | The space content leaves for the bar. A NavigationStack inside a panel pads its end by it. |
--mb-safe-top, --mb-safe-bottom | inherited | The device's safe area, from env() or a device frame. |
--active-tab-left, --active-tab-width | .mb-tab-bar-indicator | The selected tab's box, from ui-sdk. |