macbetter

Navigation

Navigation Bar

Show where you are in an app's hierarchy, with a back button, a title and the actions for the screen.

iOS only

Library

"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.

iOS only

Wi-Fi

Other networks
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. NavigationStack sets --mb-content-inset-top to 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 (h1 by default, set with headingLevel). With largeTitle, 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

The screen's scroll view. Takes the props of a div, including ref.

PropTypeDefaultDescription
titleReactNode—The screen's title.
largeTitlebooleanfalseShows the title large below the bar, collapsing into it on scroll.
headingLevel1 | 2 | 3 | 4 | 5 | 61The level of the title's heading.

A ui-sdk Button. Its children are the previous screen's title.

Containers for the bar's buttons. Take the props of a div.

Data attributes and variables

Attribute or variableOnMeaning
data-large-title.mb-navigation-barThe bar collapses a large title.
data-scrolled.mb-navigation-stackContent is under the bar. Only set in browsers without scroll-driven animations.
--mb-navigation-offset.mb-navigation-stackThe scroll position, set only in browsers without scroll-driven animations.
--mb-safe-top, --mb-safe-left, --mb-safe-rightinheritedThe device's safe area. Defaults to env(safe-area-inset-*); a device frame sets it for its screen.
--mb-content-inset-top.mb-navigation-stackSet to the bar's height (with the safe area), for sticky content inside the stack.
--mb-content-inset-bottominheritedSpace below the content, like a tab bar's height. Set by TabBar; falls back to --mb-safe-bottom.
--mb-navigation-bar-rowinheritedThe height of the bar's row: 44px on iPhone, 50px on iPad, the bar height on macOS.
--mb-navigation-large-title-heightinheritedThe large title's row, and the scroll distance over which it collapses.
--mb-navigation-bar-title-clipinheritedA clip-path for the inline title. An iPadOS tab bar sets it to hide the title visually.