⚠️ Design system CSS not found. Check the path in cms/docs.config.js → designSystemPath, then re-run npm run docgen.

App Navigation

The app's primary navigation — tab bar and icon rail in one component

Website / App Navigation
Download .md file
Open .md in new tab
On this page
  • Anatomy
  • Basic usage
  • Layouts
  • Content clearance
  • Badge
  • Accessibility
  • Usage rules
  • CSS reference
  • Use in another product

App Navigation is the persistent way around an app: three to five top-level
destinations, always visible, one always current. The class is
required on a <nav> element. Each destination is a link ()
carrying an icon, an always-visible label and an optional count badge. The
same markup renders two layouts — a bottom tab bar on small screens, a left
icon rail on large ones — set out under Layouts below.

Anatomy

Part Class Notes
Container <nav> with an aria-label; owns position, background, border
Destination <a href>; the current one carries aria-current="page"
Icon Sizes the glyph and anchors the badge
Label Always visible — never icon-only
Badge Optional count bubble, aria-hidden="true"
Attribute On Values What it does
data-layout container bar, rail Pins one layout at every width; omit for the responsive default
aria-current link page Declares and styles the current destination

Basic usage

The demo pins data-layout="bar" and sits in the flow, so it stays inside
this box at any window size. A real instance carries neither constraint: the
component picks its layout from the viewport and fixes itself to the edge.

Home
Search
3
Saved
Profile
<nav class="app-nav" aria-label="Primary">
  <a class="app-nav-link" href="/" aria-current="page">
    <div class="app-nav-icon">
      <div class="svg-icn" data-icon="home">…</div>
    </div>
    <span class="app-nav-label">Home</span>
  </a>
  <a class="app-nav-link" href="/search">
    <div class="app-nav-icon">
      <div class="svg-icn" data-icon="search">…</div>
    </div>
    <span class="app-nav-label">Search</span>
  </a>
  <a class="app-nav-link" href="/saved">
    <div class="app-nav-icon">
      <div class="svg-icn" data-icon="bookmark">…</div>
    </div>
    <span class="app-nav-label">Saved</span>
  </a>
  <a class="app-nav-link" href="/profile">
    <div class="app-nav-icon">
      <div class="svg-icn" data-icon="user">…</div>
    </div>
    <span class="app-nav-label">Profile</span>
  </a>
</nav>

Layouts

The component is responsive by default: the bar below 960px, the rail from
960px — the same boundary the rest of the system treats as the desktop tier.
data-layout pins one layout at every width:

<nav class="app-nav" data-layout="bar" aria-label="Primary">…</nav>
<nav class="app-nav" data-layout="rail" aria-label="Primary">…</nav>

Pinning rail forfeits the responsive collapse: at 320px effective width —
a phone, or 400% zoom — a 72px rail plus its matching content padding claims
almost half the viewport. Test at that width before pinning; the responsive
default already switches to the bar there and is safe.

The rail demo pins its layout and shows the same four destinations as the
bar above — only the layout changes:

Home
Search
3
Saved
Profile

A product with a site header docks the rail below it by re-pointing the
offset knob — never by overriding the rail selector:

:root {
  --app-nav-inset-block-start: var(--site-header-height);
}

Content clearance

The app shell pads for the navigation — the component cannot. It is
position: fixed and has no way to know which container scrolls, so content
runs underneath it until the scrolling column reserves the space. Compose
the safe-area token in the same calc so the padding follows on installed
apps:

.app-shell {
  padding-bottom: calc(var(--app-nav-height) + var(--safe-area-bottom));
}

@media (min-width: 960px) {
  .app-shell {
    padding-bottom: 0;
    padding-inline-start: calc(var(--app-nav-rail-width) + var(--safe-area-left));
  }
}

Toasts share the bottom edge with the bar. An app shell running one
re-points the Toast offset so notifications stack
above the tab bar instead of covering it:

.toast-container {
  --toast-offset-bottom: calc(var(--app-nav-height) + var(--safe-area-bottom) + var(--space-m));
}

Badge

The badge is a count bubble anchored to the icon's corner — it says
"something new is here", and the number it shows must also reach assistive
technology. The bubble is presentational: put the real count in the link's
aria-label, and mark the bubble aria-hidden="true" so the number is not
read twice.

<a class="app-nav-link" href="/saved" aria-label="Saved, 3 new">
  <div class="app-nav-icon">
    <div class="svg-icn" data-icon="bookmark">…</div>
    <span class="app-nav-badge" aria-hidden="true">3</span>
  </div>
  <span class="app-nav-label">Saved</span>
</a>

Start the aria-label with the visible label text, exactly as written —
that is what keeps voice control working. And when the count is dynamic,
the script that updates the bubble updates the aria-label in the same
breath; a badge saying 5 while the name says 3 is worse than no badge. Keep
counts to two digits; show 99+ beyond that.

Accessibility

  • The container is a <nav> landmark and needs an aria-label that is
    unique among the page's navigation landmarks and never contains the word
    "navigation" — screen readers append the role, so Primary reads as
    "Primary navigation".
  • The current destination carries aria-current="page" — it is both the
    accessible state and the visible style, so the two cannot drift. Update it
    on navigation.
  • Labels are always visible. Icon-only navigation trades a word of space for
    a guess, and the system's Tooltip is not a
    substitute here.
  • Every destination clears --target-min (44px) in both axes; the bar's
    56px height leaves room around it.
  • Badges are aria-hidden with the count folded into the link's
    aria-label (see Badge above).
  • In forced-colors mode the current label is underlined and the badge keeps
    an outline, because the colours that normally carry both are erased.

Usage rules

Do:

  • Keep the set to three to five destinations. The bar splits its width
    evenly, so a sixth shrinks every label past reading and squeezes targets
    toward the floor. Beyond five, reach for a
    Drawer or a menu page.
  • Run one per page. It is the primary navigation, not a toolbar —
    a control strip that acts on the content below it is a
    Bar.
  • Give it links. A destination navigates; an action (open a dialog, start a
    capture) is a <button> and belongs on the screen it acts on.
  • Re-skin through the --app-nav-* tokens — an override of the selectors
    survives until the next release, a token re-skin travels with the brand
    (Source of Truth). Overridden colour pairs
    must hold 4.5:1: the labels are small text at every size.

Don't:

  • Place it inside a header, a section or a scroll container — it is fixed
    viewport chrome and positions itself against the viewport.
  • Leave it visible on a full-screen flow. A player, a capture screen or
    onboarding hides it with the shared state class:
    class="app-nav is-hidden".

CSS reference

This section documents how the component is built. For usage, see the
sections above.

Tokens

Token Default What it controls
56px Bar layout height (safe-area inset added on top)
72px Rail layout width (safe-area inset added on top)
Stacking slot
0px Rail top edge — re-point to dock below a site header
Gap between rail destinations
Gap between a destination's icon and label
Rail top and bottom padding
Surface colour
Dividing edge — top of the bar, end of the rail
Resting icon and label colour
Hover colour
Current destination colour
1.5rem Icon glyph size
Label size — steps 14px under 960px, 12px above
1.125rem Badge bubble size
Badge count size
Badge fill
Badge count colour

The stacking slot --z-app-nav (90) lives with the sticky-stack scale:
above bars (40), below the site header (100), and below the overlays that
must clear everything — tooltip (800), toast (900), dropdown (1000).
Dialogs and drawers use the native top layer and outrank all of it.

Selectors

Selector Role
Fixed container; bar geometry is the base layout
.app-nav[data-layout="rail"] Rail geometry pinned at every width
.app-nav:not([data-layout="bar"]) at ≥960px Rail geometry at the desktop tier
Destination; equal-width in the bar, full-width in the rail
.app-nav-link:hover Hover colour
.app-nav-link[aria-current="page"] Current destination colour and label weight
Glyph sizing and badge anchor
Always-visible label
Count bubble, absolutely positioned off the icon

Key rules

  • The rail's declarations appear twice — once for the pinned attribute, once
    inside the desktop media query — because a media query cannot join a
    selector list. Change both together.
  • The current-state switch has no dedicated animation — platform tab bars
    snap. Hover transitions on the motion primitives, the same as .
  • The badge count lives in the link's accessible name; the bubble is
    presentational, and its geometry re-skins through --app-nav-badge-size
    and --app-nav-badge-font-size.

Use in another product

The design system installs once per product:

npm install github:bydefaultstudio/design-system-dist#semver:^4.7.0

This component's styles ship in design-system.css. No JavaScript, nothing else to include.

On this page
  • Anatomy
  • Basic usage
  • Layouts
  • Content clearance
  • Badge
  • Accessibility
  • Usage rules
  • CSS reference
  • Use in another product
Previous Bar
Next Avatar

Was this page helpful?

We use this feedback to improve our documentation.

Thanks for your feedback

Send feedback

© 2026 By Default