Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Alignment
Theme
Dir
View
Options

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 navigation

The leading slot holds the trigger; the panel it opens is a Drawer. The bar's whole part in this is the button, its accessible name, and the aria-expanded / aria-controls pair that says what it owns.

What opens is a choice this page does not make. See Drawer for navigation that slides over the content, Sidebar for a column that stays, Menu for a short list hung off the trigger, and Mega Menu for a wide grouped panel.

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.

DefaultIdle glyph, no fill
Hover--ds-layer-hover
Pressed--ds-layer-active
Focus visible2px ring, 2px offset
DisabledRare — prefer removing
Trigger, expandedaria-expanded="true"
Acme Analytics Cloud
Title, truncatedNever wraps to a second line
AccountAvatar and chevron, one target

Anatomy

Every part, every measurement, and the reason it is that number.

UI Bible
40

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.

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

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

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

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

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

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

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
Layout
--ds-layout-containerContent cap. Owned by Grid & Layout — the bar aligns to it rather than declaring one
--ds-layout-gutter-lgInline padding at a 640px container and above
--ds-layout-gutterInline padding below a 640px container
row heightRegular and compact

Shadow

TokenValueUsed for
Planes
--shadow-e2—Elevation in the light theme; in dark the lift is carried by surface lightness alone

Typography

TokenValueUsed for
Foreground
--text-h3Title at full width
--text-h4Title below a 640px container

Motion

TokenValueUsed for
Interaction
--ease-standardThe 160ms background and shadow transition when elevation changes

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingTypeTouch targetWhen to use
Desktop64px40px gutter19px title—Container ≥ 640px. Three utilities plus the account. The row caps at 76rem and centres with the content column beneath it.
Tablet64px40px gutter19px title44px tallThe same row as desktop — the bar has one breakpoint, not three. What changes is the pointer: coarse input grows the targets, width does not.
Mobile56px24px gutter16px title44px tallContainer < 640px. Two utilities plus the account; anything further moves into a menu. Navigation is a trigger, never a visible row of destinations.
Full bleed64 / 56px8px gutter——No content cap. For editor and tool chrome that owns the whole window — wrong for a bar heading a document column.

Do

Acme Analytics Cloud — Production
Let the title truncate; keep the actionsThe brand track is min-w-0 so it gives way first. A title that wraps makes the bar two rows tall and moves every control below the fold; a title that pushes the actions off the end loses them silently. One line, an ellipsis, and the full string in the accessible name.
<IconButton label="Notifications" icon={<Bell />} /><button><Bell /></button>
Name every icon-only controlAn icon with no accessible name is announced as "button" and nothing else. IconButton makes `label` required for exactly this reason, and the name is the words a person would say — "Notifications", not "bell".
global only
Keep it to actions that apply everywhereSearch, notifications, help, account. If an action only makes sense on one screen, it belongs to that screen — in a Toolbar, or beside the thing it acts on. The bar is the one row that does not change, and every screen-specific control put in it takes that away.
Choose elevation or a border, not bothThey answer the same question — where does the bar end and the page begin — and doing both draws a shadow under a line, which reads as a seam. Flat while the page is at the top and elevated once it scrolls is the usual pairing; a hairline is the quieter alternative for a dense tool.

Don't

FilterSortExportToolbar
Do not put page controls in the barFilters, sort, save, export, view switches. They belong to one screen, and putting them in the row that is meant to be constant means the constant row changes on every navigation — which is the one thing that made it worth its 64px.
Acme
Overview Activity Members
Do not grow the bar to a second rowA tab row, a breadcrumb trail or a search field stacked inside the bar makes it two rows tall and gives the brand a second job. Those are separate rows under it, each with its own component and its own alignment to the content column.
<header><h1>Acme</h1></header><header><span>Acme</span></header> <main><h1>Deployments</h1>
Do not make the product name the page headingThe bar is a banner landmark, not the document outline. Marking the brand as an h1 gives every screen in the product the same heading and leaves the actual page title one level down, which is what a screen-reader user navigates by.
36px is the floor
Do not shrink controls to fit more inThe fix for a crowded bar is fewer things in it, not smaller ones. Below 36px the glyphs stop being recognisable at a glance and the targets stop clearing the spacing they need — and the utility that got squeezed in was, by definition, the least important one there.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.1Bypass BlocksA4.1.2Name, Role, ValueA2.4.7Focus VisibleAA2.5.8Target Size (Minimum)AA

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

TabMoves 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 / SpaceActivates the focused control. A trigger opens its panel and moves focus into it.
EscapeCloses 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.
AttributeApplied toNotes
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-labelIcon-only buttonsRequired. The name is what the control does — "Open navigation", "Notifications", "Account menu, Ada Lovelace".
aria-expanded + aria-controlsThe navigation triggerSays 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-haspopupThe 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 screenOnly if the bar carries destinations at all. Colour alone does not say "you are here".

Code

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.

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

css
/* 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

PropTypeDefaultDescription
title*ReactNode—The product name or screen title. One line — the bar truncates rather than wraps.
logoReactNode—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.
leadingReactNode—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.
actionsReactNode—Slot 3: global utilities. Three at desktop width, two below a 640px container.
accountReactNode—Slot 4: the signed-in person, separated from the actions by a wider gap.
elevatedbooleanfalseLifts the bar off the page: --ds-surface plus --shadow-e2. Dark raises by lightness, light by shadow.
borderedbooleanfalseA hairline along the bottom edge. Use it INSTEAD of elevation — both together reads as a seam.
fullBleedbooleanfalseRuns 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.
maxWidthstring | numbervar(--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.
stickybooleantrueSticks to the top of its scroll container at z-10. Pair with scroll-padding-top so focused elements are not hidden underneath.

Notes

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.