Architect
How-to guides
How-toNormative

Name and wire navigation

One destination list feeding the rail, the trail and the home cards — so a screen cannot be called two things by two people.

Use this when adding navigation to an application, or when a screen has started going by two names.

1. Declare the destinations once

export interface NavigationItem {
  label: string
  to: RoutePath
  icon: Icon
  /** "/" is the prefix of everything: without this the home link stays active everywhere. */
  exact?: boolean
}

export interface NavigationGroup {
  /** null for the leading group: a heading above the first link teaches nothing. */
  label: string | null
  items: readonly NavigationItem[]
}

export const NAVIGATION_GROUPS: readonly NavigationGroup[] = [ … ]

One list feeds the rail, the breadcrumb trail, and any home-screen cards pointing at the same places. A navigation and a home screen that describe themselves separately end up contradicting each other, and the reader is left to decide which is right.

Order by real dependency, not alphabetically and not by importance. Somebody following the list top to bottom should never meet a screen that requires something they have not done yet.

Permission-dependent destinations stay out. A rail showing everyone what their permissions refuse teaches the default-deny in the worst possible way.

2. Label with nouns

A rail says where you are. A verb announces an act nobody asked for, and it is always longer.

Verb phraseNoun
Applications in progressApplications
Report of applicationsReports
Review circuitCircuit
Supporting documentsDocuments
Administered listsLists

Two words maximum. The brevity is not a style preference — it is what makes a rail readable at a glance, and it falls out of the noun rule on its own.

An act with no good noun does not belong in the rail. "Register an application" is a command; it lives on the home card and on the button in the header of the page where it is exercised. It keeps a breadcrumb, so the screen still names itself.

Never reuse a label. If Lists is already a settings section, the read-only reference screen needs a different word — otherwise the reader must open one to find out which it is.

This is the designer lens applied to navigation: say the label out loud in a sentence a real person would speak. "I'm in Applications" works. "I'm in Register an application" does not.

3. Mark the active item with a data attribute

<Link
  activeOptions={{ exact: item.exact ?? false }}
  activeProps={{ "data-active": "true" }}
  className="… data-[active=true]:bg-black/[0.06] data-[active=true]:font-semibold"
/>

Not activeProps={{ className: … }}. How a router merges an active class with the base class is a rule nobody re-reads, and a background that wins one time in two is a defect that takes an hour to find. A data attribute has no merge semantics to get wrong.

4. Derive the breadcrumb trail

const fromNavigation = new Map<string, string>([
  ...NAVIGATION_GROUPS.flatMap((g) => g.items.map((i) => [String(i.to), i.label] as const)),
  ...SETTINGS_SECTIONS.map((s) => [String(s.to), s.label] as const),
])

export function crumbsFor(pathname: string): Crumb[] {
  const crumbs: Crumb[] = [{ label: "Home", to: "/" }]
  let path = ""

  for (const segment of pathname.split("/").filter(Boolean)) {
    path += `/${segment}`
    const label = fromNavigation.get(path) ?? EXTRA[path]
    if (label) crumbs.push({ label, to: path })
  }

  // the current page is not a link to itself
  const last = crumbs.at(-1)
  if (last) crumbs[crumbs.length - 1] = { label: last.label }

  return crumbs
}

Giving every route its own crumb creates one place per route where a screen's name can drift from the one in the rail. Derive the trail from the pathname, resolving each accumulated prefix against the navigation lists, and keep a small table only for what the navigation deliberately does not name — wizard steps, scoped queues.

An unknown segment is an identifier; skip it. A generated id in a trail teaches nobody anything and occupies the space a word would have used.

const looksLikeId = (s: string) => /^[a-z]{2,8}_[0-9a-z]{12}$/.test(s)

The last crumb is the page and is not a link to itself. Give it aria-current="page" and the heavier weight — that is where "breadcrumb and title" resolves into one line rather than two.

Intermediate crumbs collapse on narrow screens. What you need there is where you are, not how you got there.

Finished when

  • Every destination is declared in exactly one list.
  • Every rail label is a noun of at most two words, and no two labels are the same.
  • The breadcrumb for any route resolves without a per-route declaration.
  • No generated identifier appears in a trail.

On this page