App Bar
The persistent bar across the top of a screen. It holds three things: the way into navigation, what you are looking at, and the few actions that apply everywhere in the product. Anything belonging to the current screen goes below it.
Also called Top App Bar — in this system that is App Bar.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Preview
Desktop · 1024px+Default
The bar on its own: a navigation trigger, the brand, two or three global utilities, and the signed-in account. Every control above changes an arrangement or a plane — none of them add a part.
With tabs
Tabs switch between views of the screen you are already on, so they sit in a row under the bar and the bar does not change as you move between them. Their own rules — counts, overflow, keyboard model — are on the Tabs page.
The two rows are one block, so the elevation belongs to the pair rather than to the bar, and the tab rail is the bottom edge — which leaves the Border option nothing to do here. See Tabs.
The bar reflows on its own width, not the window's, so a bar inside a 390px panel is compact for the same reason one in a 390px window is. Pop it out to see it against a real viewport, where 100dvh, the safe-area insets and a coarse pointer are all true rather than simulated. RTL mirrors the whole bar, so the alignment names describe where things sit reading left to right.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Four slots on one 64px row, drawn at desktop width. Slot 1 stays in the layout even when it holds nothing, so the brand does not shuffle sideways between a screen with a back arrow and one without. The live bar above is the real component — resize it to watch the row drop to 56px and the third utility leave.
- Row height64px · 56px compact
Drops to 56px below a 640px CONTAINER, not viewport — the bar reflows on its own width, so one inside a 390px panel is compact for the same reason one in a 390px window is. 56px is the smallest row that still holds a 44px target with room around it, which is why it is the near-universal phone height.
- Slot 1 — leading36px control, always present
The way out: a navigation trigger, a back arrow, or nothing. It holds its track when empty, which is what keeps the brand from moving between screens.
- Slot 2 — brand or title--text-h3 · --text-h4 compact
Mark plus product name, or the screen title. The mark is the one thing in the bar allowed to carry the accent. min-w-0 on this track is what makes the title truncate instead of shoving the actions off the end.
- Slot 3 — actions2px gap between controls
Global utilities only. Three fit comfortably at desktop width and two below 640px; past that, move the rest into a menu rather than shrinking the controls.
- Slot 4 — account6px separation
The signed-in person, set apart by a gap wider than the 2px between utilities. That gap is the whole statement: the account is not the fourth action. Avatar and chevron are one button, so the disclosure never becomes a separate 24px target.
- Gutter and content capFrom Grid & Layout
The bar reads --ds-layout-gutter (24px), --ds-layout-gutter-lg (40px) and --ds-layout-container (76rem) — the same three values the page under it uses, which is what lines the brand up with the first column of content. Full-bleed gives both up: right for editor chrome, wrong for a bar heading a document.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| Planes | ||
| --ds-canvas | — | Bar background when flat — the same plane as the page |
| --ds-surface | — | Bar background when elevated |
| --ds-border-subtle | — | The bordered hairline. Use instead of elevation, never with it |
| Foreground | ||
| --ds-fg | — | Title |
| --ds-fg-muted | — | Idle icon glyphs and the account chevron |
| --ds-accent-text | — | The brand mark — the only accent in the bar |
| Interaction | ||
| --ds-layer-hover | — | Hover fill on any control in the bar |
| --ds-layer-active | — | Pressed fill, and a trigger while its panel is open |
| --ds-focus-ring | — | Focus outline, 2px at 2px offset |
Spacing
| Token | Value | Used for |
|---|---|---|
| Layout | ||
| --ds-layout-container | Content cap. Owned by Grid & Layout — the bar aligns to it rather than declaring one | |
| --ds-layout-gutter-lg | Inline padding at a 640px container and above | |
| --ds-layout-gutter | Inline padding below a 640px container | |
| row height | Regular and compact | |
Shadow
| Token | Value | Used for |
|---|---|---|
| Planes | ||
| --shadow-e2 | — | Elevation in the light theme; in dark the lift is carried by surface lightness alone |
Typography
| Token | Value | Used for |
|---|---|---|
| Foreground | ||
| --text-h3 | Title at full width | |
| --text-h4 | Title below a 640px container | |
Motion
| Token | Value | Used for |
|---|---|---|
| Interaction | ||
| --ease-standard | The 160ms background and shadow transition when elevation changes | |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Type | Touch target | When to use |
|---|---|---|---|---|---|
| Desktop | 64px | 40px gutter | 19px title | — | Container ≥ 640px. Three utilities plus the account. The row caps at 76rem and centres with the content column beneath it. |
| Tablet | 64px | 40px gutter | 19px title | 44px tall | The same row as desktop — the bar has one breakpoint, not three. What changes is the pointer: coarse input grows the targets, width does not. |
| Mobile | 56px | 24px gutter | 16px title | 44px tall | Container < 640px. Two utilities plus the account; anything further moves into a menu. Navigation is a trigger, never a visible row of destinations. |
| Full bleed | 64 / 56px | 8px gutter | — | — | No content cap. For editor and tool chrome that owns the whole window — wrong for a bar heading a document column. |
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Idle glyphs use --ds-fg-muted, which clears 4.5:1 on both --ds-canvas and --ds-surface. Do not drop them to --ds-fg-disabled to make the bar quieter — that token is 2.66:1 and is exempt from contrast rules precisely because nothing readable may use it.
- The focus ring is 2px at 2px offset and must reach 3:1 against both the bar and whatever sits behind it. An elevated bar changes the backdrop, so check the ring in both planes.
- Hover fill alone is not a state for anyone who cannot see it. Every control here also carries a name, and a trigger carries aria-expanded.
Keyboard
| Tab | Moves through the bar in reading order: leading, brand link if it is one, each action, then the account. Every control is a real button or link — nothing in the bar is reachable only by pointer. |
| Enter / Space | Activates the focused control. A trigger opens its panel and moves focus into it. |
| Escape | Closes a panel opened from the bar and returns focus to the trigger that opened it — not to the top of the document. |
| Tab (first stop) | A skip link before the bar jumps past it to the main content. Without one, every keyboard user crosses four to six controls on every page load. |
Screen readers
- The bar is a banner landmark, so it is announced as a region and can be skipped as one. Keep it that way: a <div> with a class named "app-bar" is invisible to that navigation.
- The product name is not the page heading. The screen names itself in <main>, with one h1 per page.
- A count on a notification icon must be in the accessible name — "Notifications, 3 unread" — not only in a badge. A badge is a picture of a number.
Focus & touch
- The ring is never removed, only restyled: 2px at 2px offset, on every control including the brand. A sticky bar also hides whatever the browser scrolls a focused element to, so the scroll container needs scroll-padding-top equal to the bar height — without it, tabbing into the page puts focus under the bar.
- Controls are 36×36 and the pointer target is extended to 44px tall on coarse pointers, which clears the 24×24 that WCAG 2.5.8 requires at AA with room to spare. The extension is the control's own width, so adjacent targets never overlap and the 2px visual gap is safe. For a touch-first product, use the lg size instead — 44×44 in a 64px row — rather than adding margin between smaller controls.
| Attribute | Applied to | Notes |
|---|---|---|
| role="banner" | <header> | One per page. It is the landmark screen-reader users jump to for the product-level controls, and a second one makes both ambiguous. |
| aria-label | Icon-only buttons | Required. The name is what the control does — "Open navigation", "Notifications", "Account menu, Ada Lovelace". |
| aria-expanded + aria-controls | The navigation trigger | Says whether the panel is open and which element it is. Without the pair the button announces the same thing before and after it works. |
| aria-haspopup | The account control | "menu" when it opens a menu. It tells the user a press opens something rather than navigating. |
| aria-current="page" | A link in the bar to the current screen | Only if the bar carries destinations at all. Colour alone does not say "you are here". |
Example usage
The bar is one component in every case. What changes is what its leading slot opens and what sits under it — neither of which is the bar's concern.
1function Header() {2 const [navOpen, setNavOpen] = React.useState(false)34 return (5 <>6 {/* First tabbable thing on the page, so the bar can be skipped. */}7 <a href="#main" className="skip-link">Skip to content</a>89 <AppBar10 title="UI Bible"11 logo={<Box size={22} className="text-accent-text" />}12 align="left" // 'left' | 'center' | 'right'13 elevated // surface + shadow. Use INSTEAD of bordered14 // fullBleed // drop the content cap and the gutters15 leading={16 <IconButton17 label={navOpen ? 'Close navigation' : 'Open navigation'}18 icon={<Menu />}19 aria-expanded={navOpen}20 aria-controls="primary-nav"21 onClick={() => setNavOpen((o) => !o)}22 />23 }24 actions={25 <>26 <IconButton label="Help" icon={<HelpCircle />} />27 <IconButton label="Notifications, 3 unread" icon={<Bell />} />28 {/* The third utility drops on a narrow CONTAINER, not a narrow29 window — the bar reflows on its own width. */}30 <span className="@max-[640px]:hidden">31 <IconButton label="Settings" icon={<Settings />} />32 </span>33 </>34 }35 account={<AccountButton user={user} />}36 />3738 {/* What the trigger opens is a Drawer, and it is a sibling of the bar.39 See the Drawer page — none of its rules live here. */}40 <Drawer id="primary-nav" open={navOpen} onClose={() => setNavOpen(false)} side="left">41 <PrimaryNav />42 </Drawer>4344 {/* Tabs are a row UNDER the bar, aligned to the same column. */}45 <main id="main">…</main>46 </>47 )48}CSS
The two rules that are easy to miss: the bar aligns to the page, and a sticky bar has to be accounted for when something is scrolled into view.
/* The cap and the gutters are the page's, not the bar's — see Grid & Layout.
A bar that declares its own numbers agrees with the content column by
coincidence, and stops agreeing the moment either side is edited. */
.app-bar__row {
max-inline-size: var(--ds-layout-container); /* 76rem */
margin-inline: auto;
padding-inline: var(--ds-layout-gutter); /* 24px */
block-size: 3.5rem; /* 56px compact */
}
@container (min-width: 640px) {
.app-bar__row {
padding-inline: var(--ds-layout-gutter-lg); /* 40px */
block-size: 4rem; /* 64px */
}
}
/* A sticky bar covers whatever the browser scrolls to. Without this, tabbing
into the page puts the focused element underneath the bar. */
html {
scroll-padding-top: 4rem;
}Component API
AppBar
| Prop | Type | Default | Description |
|---|---|---|---|
| title* | ReactNode | — | The product name or screen title. One line — the bar truncates rather than wraps. |
| logo | ReactNode | — | The mark before the title. Square, and the only thing in the bar that carries the accent. |
| align | 'left' | 'center' | 'right' | 'left' | Where the brand block sits. Center switches the grid to equal side tracks so it is centred against the bar rather than against whatever the slots happen to weigh. |
| leading | ReactNode | — | Slot 1: a navigation trigger or a back arrow. The slot holds its track even when this is undefined, so the brand does not move between screens. |
| actions | ReactNode | — | Slot 3: global utilities. Three at desktop width, two below a 640px container. |
| account | ReactNode | — | Slot 4: the signed-in person, separated from the actions by a wider gap. |
| elevated | boolean | false | Lifts the bar off the page: --ds-surface plus --shadow-e2. Dark raises by lightness, light by shadow. |
| bordered | boolean | false | A hairline along the bottom edge. Use it INSTEAD of elevation — both together reads as a seam. |
| fullBleed | boolean | false | Runs the contents to the window edges: no content cap, gutter down to 8px. Right for editor chrome, wrong for a bar heading a document column. |
| maxWidth | string | number | var(--ds-layout-container) | The content column the bar aligns to. Defaults to the layout token, so overriding it is how a bar heads a narrower column — not how it invents a width. |
| sticky | boolean | true | Sticks to the top of its scroll container at z-10. Pair with scroll-padding-top so focused elements are not hidden underneath. |
Professional tips
- Flat at the top of the scroll, elevated once the page moves. It is the cheapest way to say "there is more above" and it costs one scroll listener and a 160ms transition.
- Centre the title only when the bar has nothing else to hold. With a trigger on one side and three utilities on the other, a centred title is off-centre against its own slots on every screen where the two sides differ in width.
- On a phone, a back arrow and a screen title are usually more useful than the brand. The brand is already established once; where you are is the question the bar is being asked.
- Count the controls before adding one. Four global utilities plus an account is where a bar starts reading as a toolbar, and the fourth is almost always a candidate for the account menu.
Performance
- Elevation on scroll should be driven by an IntersectionObserver on a sentinel element, not a scroll handler that runs on every frame.
- The bar is sticky, not fixed: sticky stays inside the document flow, so nothing below it needs a matching top offset that can drift out of sync with the height.
- Transition background-color and box-shadow only. Animating the height reflows every frame and makes the whole page jump as the bar changes size.
Common mistakes
- Hiding the bar on scroll down and revealing it on scroll up. It saves 64px and costs the user the one row they expected to be constant — and on a short page it can make the bar unreachable without scrolling.
- Using a fixed pixel width for the content cap instead of the layout token, so the brand sits a few pixels away from the column it is supposed to head.
- Icon-only controls with a `title` and no accessible name. A tooltip is not a name: it never reaches a screen reader and never appears on touch.
- A bar that becomes two rows on a phone. That is the width where there is least room for it, and it is usually a sign that screen-level controls got in.
Real-world recommendations
- Tab through the page from a cold load. If you cross the whole bar before reaching the content, the skip link is missing — and it is the single highest-value fix on this component.
- Open the product on a 1440px monitor and check the brand against the first column of content beneath it. If they are a few pixels apart, something is carrying its own copy of the cap.
- Give the product name to a customer with a long company name before shipping. "Acme" fits everything; "Northwestern Mutual Benefits Administration" is what actually arrives.