Skip to content

Navigation

A UX principle for coding agents. Also covers nav, wayfinding, breadcrumbs, back navigation, command palette.

Navigation is a spatial hierarchy, not a list of links. Before a user reads a single label they are asking three questions: where am I, how did I get here, and where can I go next. A good navigation system answers all three at a glance and never makes the user reconstruct the answer from memory.

The shell is the last thing you should design, not the first (see design-process.md — "Start with a Feature, Not a Layout"). But once features exist, the structure that connects them carries more of the product's usability than any single screen. Get it wrong and every screen inherits the confusion.

Primary Navigation Patterns

There are three durable shapes for top-level navigation. Pick by item count, screen width, and how often users switch sections, not by fashion.

Top navigation bar:

  • Best for 3–7 top-level destinations and content-first products (marketing, docs, e-commerce).
  • Horizontal space is scarce — past ~7 items the bar wraps or overflows. Collapse the tail into a "More" menu before it wraps.
  • Keep the logo as a persistent home affordance on the left; primary actions (account, cart, CTA) on the right.

Sidebar:

  • Best for app-like products with many destinations, nested sections, or tools that need a persistent workspace (dashboards, editors, admin).
  • Scales vertically — it tolerates more items than a top bar and supports grouping with section headers.
  • Offer a collapsible rail: full width shows icon + label, collapsed shows icon-only with the label in a tooltip. Persist the collapsed/expanded choice (see "Navigation State Lives in the URL", and interactions.md — URL as State, for the broader principle).

Bottom tab bar (mobile):

  • Best for 3–5 peer destinations on phones — the thumb reaches the bottom, not the top.
  • Never more than 5 tabs. The fifth is often a "More" tab; past that, items become unreachable by memory.
  • Reserve it for top-level switching, not actions. A tab changes where you are; it should not submit, create, or delete.

The rule: one primary navigation pattern per breakpoint. A sidebar on desktop can become a bottom tab bar on mobile, but don't run a top bar and a sidebar and tabs competing for the same top-level role on the same screen — the user can't tell which one is the source of truth for "where am I."

Secondary Navigation

Secondary navigation moves within a section the primary nav already selected. It must read as subordinate, never compete with the primary level.

  • Tabs — switch between peer views of the same object (Overview / Activity / Settings on a single project). The content swaps; the surrounding context stays. Tabs are for views that share a subject, not for unrelated destinations.
  • Segmented controls — a compact tab variant for 2–4 mutually exclusive options that filter or reshape the same content (Day / Week / Month). Use when the choices are a property of the view, not separate pages.
  • Sub-navigation — a second tier (sidebar sub-items, a secondary row under the top bar) for sections deep enough to need their own map. Keep it visually quieter than the primary level — lighter weight, smaller, lower contrast (see ui/hierarchy.md — emphasize by de-emphasizing).

Don't nest more than two navigation levels visible at once. Primary + secondary is legible; primary + secondary + tertiary all on screen turns the chrome into a maze and starves the content of space.

Always Show Where You Are

Wayfinding is the cheapest usability win in any navigation system and the most commonly skipped. If the user can't see which section is active, every screen feels like it could be any screen.

  • Mark the active destination with a clear, persistent treatment: a filled background, a weight bump, or a colored accent border (the "side border on active navigation items" from ui/polish.md — Add Color with Accent Borders).
  • The active state must be visually distinct from hover, not the same shade. Hover says "you could go here"; active says "you are here." If they look identical the user loses the anchor on pointer-move.
  • Set aria-current="page" on the active link so screen readers announce it as the current location, not just another link.
<nav aria-label="Primary">
  <a href="/dashboard" aria-current="page" class="nav-item nav-item--active">Dashboard</a>
  <a href="/projects" class="nav-item">Projects</a>
  <a href="/settings" class="nav-item">Settings</a>
</nav>
/* Active item carries a colored accent border + raised contrast.
   Hover is a separate, quieter signal. */
.nav-item--active {
  box-shadow: inset 2px 0 0 0 var(--primary); /* left accent rail */
  color: var(--text);
  background: var(--bg-light);
}

@media (hover: hover) {
  .nav-item:hover:not(.nav-item--active) {
    background: var(--bg-light);
    color: var(--text);
  }
}

For long routes, the active state should cascade: the active top-level section and the active sub-item both light up, so the user reads the full path through the chrome without a separate breadcrumb.

Breadcrumbs show the path from the root to the current page and let the user climb back up any rung. They earn their place only in genuinely hierarchical, deep structures.

Use breadcrumbs when:

  • The hierarchy is real and more than two levels deep (Catalog > Outerwear > Jackets > Product).
  • Users arrive mid-tree from search or deep links and need to understand where "here" sits.

Skip breadcrumbs when:

  • The app is flat — a top-level switch with no nesting. Breadcrumbs on a two-level app are decoration.
  • The active-state cascade already shows the full path in the sidebar.

Structure:

  • Every crumb except the last is a link; the last is the current page and is not clickable (mark it aria-current="page").
  • Use a non-actionable separator and hide it from assistive tech: it's visual punctuation, not content.
  • Truncate long trails from the middle, never the ends — the root and the current page are the two most useful crumbs. Collapse the middle into an overflow affordance.
<nav aria-label="Breadcrumb">
  <ol>
    <li><a href="/catalog">Catalog</a></li>
    <li aria-hidden="true">/</li>
    <li><a href="/catalog/outerwear">Outerwear</a></li>
    <li aria-hidden="true">/</li>
    <li><span aria-current="page">Wool Parka</span></li>
  </ol>
</nav>

Breadcrumbs are a supplement to primary navigation, never a replacement. They help users climb; they don't help users discover what else exists.

Support Browsing and Sequencing

Not every body of content is a course or a funnel. Reference content needs a map for direct access and a lightweight path for people who prefer to continue in sequence.

  • Group content by the job it serves, such as principles, worked examples, and reusable resources. Do not force a lesson number onto topics that can stand alone.
  • Give every topic a stable URL and include it in a visible table of contents. A user should be able to bookmark, share, or return to one idea directly.
  • Add descriptive Previous and Next links as an optional reading path. Label them clearly, for example, Next: Simulating physics, and use real <a> or <Link> elements so link behavior and assistive technology semantics remain intact.
  • Treat completion as useful context only when progress matters to the user's goal. Do not make nonlinear exploration look incomplete or wrong.

Back Navigation

"Where does Back go" is the question that breaks more flows than any other navigation bug. There are two distinct kinds of back, and confusing them strands the user.

  • Browser Back — reverses history. The user expects it to undo their last navigation, restoring the previous page and its scroll position. Don't fight it: use real <a>/<Link> for navigation so Back and Forward work (see interactions.md — Links are Links), and let the framework restore scroll on history traversal.
  • In-app Back — an explicit affordance ("← Projects") that goes up the hierarchy, not back in time. This is a deliberate, predictable destination: the parent of the current screen. It should land on the same place every time regardless of how the user arrived.

The two diverge constantly. A user lands on a product page from a search result: in-app Back should go to the product's category (the hierarchical parent), while browser Back returns to the search results (the temporal previous). Label and behave them differently — an in-app Back arrow that secretly calls history.back() will sometimes leave the app entirely, which feels broken.

The rule: an in-app Back affordance navigates to a known parent route. Reserve history.back() for genuine "return to where you came from" moments (a dismissable detail overlay), and never for a button the user reads as "go up."

Restore scroll position on Back/Forward so returning to a long list doesn't dump the user at the top (Next.js does this for route navigation; custom scroll containers need it wired manually — see interactions.md — Scroll Position Persistence).

Flows & Dead-Ends

Navigation isn't only the chrome; it's the guarantee that the user can always move. A flow is a complete journey, not a pile of screens, and every step in it must lead somewhere. The failure mode is the dead-end: a button that does nothing, a link to nowhere, a state the user can't leave.

  • A flow is a complete journey. Map every transition: what triggers it and where it lands. A screen reachable by no defined transition, or one the user can't leave, is a hole in the flow.
  • Every CTA has defined behavior. No onClick={() => {}}, no button that silently does nothing. If an action is out of scope for now, disable it with a tooltip or a "coming soon" note rather than leaving it inert (see Disable, Don't Hide below).
  • Every link leads somewhere. No href="#", no path that loops back on itself, no cul-de-sac (see review-rules.md — Links without href). A link is a promise of a destination.
  • Preserve the user's work across transitions. A back-press or dismissal must not silently discard unsaved input; confirm or save a draft first (see dialogs.md — Dismissal; forms.md — Unsaved Changes Warning), and keep the back destination explicit (see Back Navigation above).
  • Modals are part of the flow, not traps in it. Every modal has an explicit way out, with a close control and Escape always present (backdrop-click dismissal for low-stakes dialogs only), so it can never strand the user (see dialogs.md — Dismissal).

Disable, Don't Hide

When an action is currently unavailable, the instinct is to hide it. Usually that's wrong: a control that appears and disappears as state changes makes the interface feel unstable and leaves the user wondering where it went.

  • Disable an action that exists but is momentarily unavailable, and explain why (a tooltip, helper text). The user learns the action is real and what unlocks it, instead of hunting for a control that vanished.
  • Hide chrome that is meaningless without data. This is the deliberate exception, not a contradiction: on a first-use or zero-data screen, strip filters, sort menus, and bulk actions that do nothing until content exists (see ui/polish.md — Hide chrome that does nothing without data). The filtered-out empty state keeps its filters, because changing them is the next action.

The line: disable an unavailable action that still belongs on the screen; hide chrome that has no meaning yet. A greyed-out "Export" with "Add data to export" reads as honest; an Export that vanishes and reappears reads as broken.

If a navigation choice changes what's on screen, it belongs in the URL. The active tab, the open section, the expanded sidebar group, the selected filter — all of it should survive a refresh, a share, and a Back press.

  • A user who copies the URL and sends it should land on the same view, active tab and all. A tab tracked only in React state evaporates on reload and can't be linked.
  • Deep-link the active section so search engines, shared links, and bookmarks resolve to the right place (see interactions.md — Deep-Link Everything).
import { useQueryState } from "nuqs";

// Active tab persists in the URL: /project/42?tab=activity
const [tab, setTab] = useQueryState("tab", { defaultValue: "overview" });

The collapsed/expanded state of the sidebar is a preference, not a location — persist that in localStorage, not the URL, so it doesn't pollute shared links. The distinction: what you're looking at goes in the URL; how the chrome is arranged goes in local preferences.

Responsive Navigation

Navigation is the chrome that suffers most under a narrowing viewport. The destinations don't change; the room to show them does.

  • Don't dump everything into a hamburger by default. A hamburger hides every destination behind a click and a guess — discoverability drops the moment the labels disappear. Keep the most important 2–4 destinations visible and collapse only the tail.
  • Priority+ pattern: show as many top-level items as fit, measure the overflow, and move the rest into a "More" menu. As the viewport shrinks, items migrate into the overflow one at a time instead of the whole bar collapsing at a single breakpoint.
  • Sidebar → bottom tabs: on phones, a desktop sidebar typically becomes a bottom tab bar (thumb reach) or a slide-in drawer triggered from the top bar. Pick the drawer when there are too many destinations for 5 tabs.
  • The hamburger/drawer trigger needs an accessible name and an expanded state: aria-label="Menu", aria-expanded, and aria-controls pointing at the drawer.
<button
  aria-label="Open navigation menu"
  aria-expanded={isOpen}
  aria-controls="primary-nav-drawer"
  onClick={() => setIsOpen((v) => !v)}
>
  <MenuIcon className="size-6" aria-hidden="true" />
</button>

When the drawer opens, trap focus inside it and restore focus to the trigger on close (see interactions.md — Focus Management). Set overscroll-behavior: contain on the drawer so scrolling it doesn't scroll the page behind (see interactions.md — Overscroll Behavior).

Command Palette

A command palette (⌘K) is a keyboard-first overlay that lets power users jump to any destination or run any action by typing. It's a shortcut layer over navigation, never the only way to get somewhere.

Add one when:

  • The product has enough destinations and actions that menu-hunting is slow for frequent users.
  • Your audience is keyboard-inclined (developer tools, internal apps, productivity software).

Don't add one when:

  • It would be the primary way to navigate. The palette accelerates discovery for people who already know the map; it doesn't replace visible, browsable navigation for people who don't.

Guidelines:

  • Bind it to ⌘K (Mac) / Ctrl+K (Windows) and surface the shortcut in the UI. Keep the unit together with a non-breaking space so it never wraps: ⌘&nbsp;K (see interactions.md — Non-Breaking Spaces).
  • Open instantly, focus the input on open, and allow fuzzy search across destinations and actions.
  • Group results by type (Pages, Actions, Recent) and make every row keyboard-navigable with ↑↓ and Enter.
  • Let Escape close it and return focus to wherever the user was.

Keyboard & Accessibility

Navigation is the first thing a keyboard or screen-reader user touches, so its semantics set the tone for the whole product.

  • Wrap each navigation region in a <nav> landmark with a distinguishing aria-label ("Primary", "Breadcrumb", "Footer") so assistive tech can list and jump between them.
  • Provide a "Skip to content" link as the first focusable element, so keyboard users can bypass the nav on every page (see interactions.md — Headings & Skip Link).
  • Tab order must follow visual order. Don't use positive tabIndex to reorder (see review-rules.md — Positive tabIndex values).
  • Within a menu or tab strip, support arrow-key navigation per the WAI-ARIA Authoring Patterns — Tab moves between widgets, arrows move within one (see interactions.md — Keyboard Navigation in Lists).
  • Icon-only navigation items need an accessible name: a visible label, a tooltip, or aria-label (see review-rules.md — Icon-only buttons missing aria-labels).
  • Navigation links must be real <a> elements with href, never <div onClick> — keyboard focus, middle-click, and "open in new tab" all depend on it (see review-rules.md — Links without href).

Transitions

Navigation happens constantly, so its motion budget is near zero. The more often a user triggers a transition, the shorter and subtler it must be (see ui/animation-and-motion.md — Micro-Interaction Priorities).

  • Don't animate page-to-page navigation with full-screen slides. A slide transition adds latency to every navigation — the content is already requested, so making the user watch it arrive feels slow (see ui/animation-and-motion.md — "Cut these: Complex page transitions").
  • The active-state change on a nav item should be instant or near-instant — a 200ms background/color ease at most. This is feedback, not spectacle.
  • A sliding active indicator under a tab strip is the one place motion earns its keep: it preserves spatial continuity as selection moves. Use a shared-layout indicator that slides between positions rather than fading out and in (see ui/animation-and-motion.md — Indicator That Tracks the Active Trigger, and Shared Layout for Sliding Selections).
  • Drawer open/close should be interruptible — a user who taps the trigger twice quickly shouldn't have to wait out the animation (see ui/animation-and-motion.md — Interruptible Animations).

Checklist

Structure

  • One primary navigation pattern per breakpoint (top bar / sidebar / bottom tabs)
  • Top bar ≤ 7 items, bottom tabs ≤ 5
  • Secondary nav reads as subordinate to primary (quieter weight/contrast)
  • No more than two navigation levels visible at once
  • Reference content supports direct topic access plus an optional Previous/Next path

Wayfinding

  • Active destination is visually distinct from hover
  • aria-current="page" on the active link
  • Active state cascades through nested levels (section + sub-item)
  • Breadcrumbs only on genuinely deep hierarchies; middle-truncated, not ends

Back & State

  • In-app Back goes to a known parent route, not history.back()
  • Real <a>/<Link> for navigation (Back/Forward/new-tab work)
  • Scroll position restored on Back/Forward
  • Active tab/section persisted in the URL; chrome layout in local preferences

Flows & dead-ends

  • Every CTA has defined behavior; out-of-scope actions disabled, never silent
  • No dead links (href="#"), loops, or unreachable/inescapable screens
  • Unsaved work preserved across back/dismiss
  • Unavailable actions disabled-with-reason; only meaningless chrome hidden

Responsive

  • Important destinations stay visible; only the tail collapses (priority+)
  • Hamburger/drawer trigger has aria-label, aria-expanded, aria-controls
  • Drawer traps focus and restores it to the trigger on close
  • overscroll-behavior: contain on the drawer

Accessibility

  • Each nav region is a <nav> landmark with a distinguishing aria-label
  • "Skip to content" link is the first focusable element
  • Tab order matches visual order (no positive tabIndex)
  • Arrow-key navigation within menus/tab strips
  • Icon-only items have accessible names

Motion

  • No full-screen slide transitions between pages
  • Active-state change is instant or ≤ 200ms
  • Sliding active indicator uses shared layout, not fade-out/in
  • Drawer animations are interruptible

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ux-principle({ topic: "navigation" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems