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.
<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:
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 isposition: 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'saria-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 anaria-labelthat is
unique among the page's navigation landmarks and never contains the word
"navigation" — screen readers append the role, soPrimaryreads 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-hiddenwith the count folded into the link'saria-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.