Navigation
Navigation Bar
Show where you are in an app's hierarchy, with a back button, a title and the actions for the screen.
"use client"
import { Artwork } from "macbetter/artwork"
import { Button } from "macbetter/button"
import { DeviceFrame } from "macbetter/device-frame"
import { List, ListRow, ListRowAccessory, ListRowSubtitle, ListRowTitle, ListSection } from "macbetter/list"
import {
NavigationBar,
NavigationBarBackButton,
NavigationBarTrailing,
NavigationStack,
} from "macbetter/navigation-bar"
import { EllipsisIcon, PlayIcon, PlusIcon } from "lucide-react"
import { useState } from "react"
const shows = [
{ id: "field-notes", title: "Field Notes", host: "Inès Moreau", episodes: 48, variant: "topo", palette: "forest" },
{ id: "night-shift", title: "Night Shift", host: "Kofi Mensah", episodes: 112, variant: "orbit", palette: "dusk" },
{ id: "small-batch", title: "Small Batch", host: "Wren Takahashi", episodes: 27, variant: "halftone", palette: "citrus" },
{ id: "the-long-way", title: "The Long Way", host: "Hollis Grant", episodes: 64, variant: "strata", palette: "sunset" },
{ id: "open-water", title: "Open Water", host: "Amara Okafor", episodes: 19, variant: "ribbon", palette: "ocean" },
{ id: "slow-radio", title: "Slow Radio", host: "Theo Lindqvist", episodes: 85, variant: "aurora", palette: "berry" },
{ id: "groundwork", title: "Groundwork", host: "Priya Raman", episodes: 33, variant: "glyph", palette: "ember" },
{ id: "second-draft", title: "Second Draft", host: "Jonah Weiss", episodes: 51, variant: "prism", palette: "glacier" },
{ id: "kitchen-table", title: "Kitchen Table", host: "Lucía Ferrer", episodes: 140, variant: "mosaic", palette: "sunset" },
{ id: "deep-field", title: "Deep Field", host: "Sefa Adjei", episodes: 22, variant: "rays", palette: "ocean" },
] as const
const episodes = [
"Where the river bends",
"Notes from the night ferry",
"A field guide to rain",
"The last lighthouse keeper",
"Salt, smoke and cedar",
"Two weeks above the tree line",
"What the tide leaves behind",
"Letters from a fire lookout",
"The orchard in winter",
"Following the old rail line",
]
type Show = (typeof shows)[number]
function Cover({ show, size }: { show: Show; size: number }) {
return (
<Artwork
seed={show.id}
variant={show.variant}
palette={show.palette}
shape="app"
size={size}
style={{ gridColumn: 1, gridRow: "1 / -1", marginInlineEnd: 14 }}
/>
)
}
export default function Example() {
const [open, setOpen] = useState<Show | null>(null)
return (
<DeviceFrame scale={0.78}>
{open ? (
<NavigationStack key={open.id}>
<NavigationBar title={open.title}>
<NavigationBarBackButton onClick={() => setOpen(null)}>Library</NavigationBarBackButton>
<NavigationBarTrailing>
<Button variant="plain" shape="circle" aria-label="More">
<EllipsisIcon aria-hidden="true" />
</Button>
</NavigationBarTrailing>
</NavigationBar>
<div style={{ display: "grid", justifyItems: "center", gap: 6, padding: "12px 16px 20px", textAlign: "center" }}>
<Artwork seed={open.id} variant={open.variant} palette={open.palette} size={200} radius={18} style={{ boxShadow: "var(--mb-shadow-3)" }} />
<div className="mb-text-title-2" style={{ marginTop: 12 }}>{open.title}</div>
<div className="mb-text-subheadline mb-label-secondary">{open.host} · {open.episodes} episodes</div>
<Button variant="filled" style={{ marginTop: 10, minWidth: 180 }}>
<PlayIcon aria-hidden="true" fill="currentColor" />
Latest Episode
</Button>
</div>
<List variant="plain">
<ListSection header="Episodes">
{episodes.map((episode, index) => (
<ListRow key={episode} onClick={() => {}}>
<ListRowTitle>{episode}</ListRowTitle>
<ListRowSubtitle>{`${index + 2} days ago · ${32 + index * 7} min`}</ListRowSubtitle>
</ListRow>
))}
</ListSection>
</List>
</NavigationStack>
) : (
<NavigationStack>
<NavigationBar title="Library" largeTitle>
<NavigationBarTrailing>
<Button variant="plain" shape="circle" aria-label="Follow a Show">
<PlusIcon aria-hidden="true" />
</Button>
</NavigationBarTrailing>
</NavigationBar>
<List variant="plain">
<ListSection>
{shows.map((show) => (
<ListRow key={show.id} onClick={() => setOpen(show)}>
<Cover show={show} size={56} />
<ListRowTitle>{show.title}</ListRowTitle>
<ListRowSubtitle>{show.host}</ListRowSubtitle>
<ListRowAccessory type="disclosure" />
</ListRow>
))}
</ListSection>
</List>
</NavigationStack>
)}
</DeviceFrame>
)
}When to use it
Use a navigation bar at the top of every screen in a hierarchy on iOS and iPadOS. It tells people where they are, takes them back one level, and holds the two or three actions that apply to the whole screen.
- Use a large title on the top level of a tab or app, where the title orients people. Use an inline title on the screens you push from it.
- Make the back button's label the previous screen's title, so people know where it goes. Shorten it to "Back" only when that title is long.
- Keep trailing actions to icons people recognize, like Add, Share or More. Put anything else in a menu.
- On macOS, use a toolbar in the window's title bar instead.
Usage
import {
NavigationBar,
NavigationBarBackButton,
NavigationBarLeading,
NavigationBarTrailing,
NavigationStack,
} from "macbetter/navigation-bar"Put the bar first inside a NavigationStack, the screen's scroll view. The stack's scroll position drives the bar, so the bar stays pinned while content scrolls under it.
Large title
Set largeTitle and the title shows large below the bar. As you scroll, it slides under the bar and is cut off at the bar's bottom edge, then the inline title fades in with the bar's translucent material. Choose a show above to push its screen, and Library to go back.
Inline title and back button
Without largeTitle, the title sits centered in the bar. NavigationBarBackButton draws the chevron and the previous screen's title in the accent color, and goes in the leading position on its own.
import { DeviceFrame } from "macbetter/device-frame"
import { List, ListRow, ListRowAccessory, ListRowTitle, ListRowValue, ListSection } from "macbetter/list"
import { NavigationBar, NavigationBarBackButton, NavigationStack } from "macbetter/navigation-bar"
import { Switch } from "macbetter/switch"
const networks = ["Larkspur 5G", "Harbor Guest", "Cedar & Pine", "Studio 4"]
export default function Example() {
return (
<DeviceFrame scale={0.78}>
<NavigationStack style={{ background: "var(--mb-bg-grouped)" }}>
<NavigationBar title="Wi-Fi">
<NavigationBarBackButton>Settings</NavigationBarBackButton>
</NavigationBar>
<List>
<ListSection>
<ListRow>
<ListRowTitle>Wi-Fi</ListRowTitle>
<ListRowAccessory>
<Switch aria-label="Wi-Fi" defaultChecked />
</ListRowAccessory>
</ListRow>
<ListRow href="#home-network">
<ListRowTitle>Driftwood Home</ListRowTitle>
<ListRowAccessory type="checkmark" />
</ListRow>
</ListSection>
<ListSection header="Other networks">
{networks.map((network) => (
<ListRow key={network} href={`#${network}`}>
<ListRowTitle>{network}</ListRowTitle>
<ListRowAccessory type="info" />
</ListRow>
))}
</ListSection>
<ListSection footer="Known networks join automatically. If none are available, you'll be asked before joining a new one.">
<ListRow href="#ask">
<ListRowTitle>Ask to Join Networks</ListRowTitle>
<ListRowValue>Notify</ListRowValue>
<ListRowAccessory type="disclosure" />
</ListRow>
<ListRow href="#hotspot">
<ListRowTitle>Auto-Join Hotspot</ListRowTitle>
<ListRowValue>Ask to Join</ListRowValue>
<ListRowAccessory type="disclosure" />
</ListRow>
</ListSection>
</List>
</NavigationStack>
</DeviceFrame>
)
}Actions
Put actions in NavigationBarTrailing, and anything that isn't a back button, like Cancel or Edit, in NavigationBarLeading. Use plain buttons: icon-only ones need an aria-label.
<NavigationBar title="New Event">
<NavigationBarLeading>
<Button variant="plain">Cancel</Button>
</NavigationBarLeading>
<NavigationBarTrailing>
<Button variant="plain">Add</Button>
</NavigationBarTrailing>
</NavigationBar>Platform behavior
- iOS and iPadOS. The bar is 44 points tall on iPhone and 50 on iPad, below the status bar, with a 17-point semibold title and a 34-point bold large title. It sits below
--mb-safe-top, which a device frame sets and which is the device's real safe area in a full-screen web app. - iPad with a tab bar. A floating tab bar sits in the center of the bar on iPadOS, so the inline title steps out of sight there and stays the heading for assistive technology. Use a large title on those screens.
- Sticky content.
NavigationStacksets--mb-content-inset-topto the bar's height, so a plain list's section headers pin below the bar instead of sliding under it. - The material appears only when it's needed. At the top of the scroll view the bar is clear and the content shows through. Once content scrolls under it, the chrome material and the hairline fade in over the first few points of scroll, as UIKit does with its scroll edge appearance.
- macOS. Mac apps don't use navigation bars. On a Mac page the bar takes the platform's 13-point metrics, for previews, but prefer a window toolbar.
The collapse is a CSS scroll-driven animation, so it runs off the main thread and never lags behind the finger. In browsers without scroll-driven animations, like Safari before version 26, NavigationStack listens for scroll: it cuts off the large title with the same edge and switches the inline title and material on once the large title is gone.
Accessibility
- The title is a real heading element (
h1by default, set withheadingLevel). WithlargeTitle, the large title is the heading and the inline copy is hidden from assistive technology, so the title is read once. - The back button is a real button whose name is the previous screen's title. Give a back button with no label
aria-label="Back". - The bar is never a focus trap: Tab moves from the leading button through the trailing actions into the content.
- Under
prefers-reduced-transparency, the material is opaque. - In right-to-left layouts the back button moves to the right and its chevron points right, as on iOS.
API
NavigationStack
The screen's scroll view. Takes the props of a div, including ref.
NavigationBar
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | The screen's title. |
largeTitle | boolean | false | Shows the title large below the bar, collapsing into it on scroll. |
headingLevel | 1 | 2 | 3 | 4 | 5 | 6 | 1 | The level of the title's heading. |
NavigationBarBackButton
A ui-sdk Button. Its children are the previous screen's title.
NavigationBarLeading, NavigationBarTrailing
Containers for the bar's buttons. Take the props of a div.
Data attributes and variables
| Attribute or variable | On | Meaning |
|---|---|---|
data-large-title | .mb-navigation-bar | The bar collapses a large title. |
data-scrolled | .mb-navigation-stack | Content is under the bar. Only set in browsers without scroll-driven animations. |
--mb-navigation-offset | .mb-navigation-stack | The scroll position, set only in browsers without scroll-driven animations. |
--mb-safe-top, --mb-safe-left, --mb-safe-right | inherited | The device's safe area. Defaults to env(safe-area-inset-*); a device frame sets it for its screen. |
--mb-content-inset-top | .mb-navigation-stack | Set to the bar's height (with the safe area), for sticky content inside the stack. |
--mb-content-inset-bottom | inherited | Space below the content, like a tab bar's height. Set by TabBar; falls back to --mb-safe-bottom. |
--mb-navigation-bar-row | inherited | The height of the bar's row: 44px on iPhone, 50px on iPad, the bar height on macOS. |
--mb-navigation-large-title-height | inherited | The large title's row, and the scroll distance over which it collapses. |
--mb-navigation-bar-title-clip | inherited | A clip-path for the inline title. An iPadOS tab bar sets it to hide the title visually. |