Reference · Components and layout
Site navigation
This is a spec: descriptive guidance, not a checkable rule. It carries no forbidden and instead pair, and no jig check detector applies to it directly.
The spec
Compose it for the phone first. Mobile navigation is a different control — not the wide row made smaller (D-111).
Choose by what fits at the narrowest width you support (360px if nothing is decided). Stop at the first row that works:
| At that width | Use |
|---|---|
Every destination fits, each at least --size-touch-target | Show them all. Wrapping to a second row is fine (E-61) |
| The one or two most important fit beside the site name, the rest do not | Show those, plus a button labelled Menu for the rest |
| Nothing fits beside the site name | A button labelled Menu for all of them |
product, three to five top-level sections used repeatedly | A bar of labelled items along the bottom edge — the same items, in the same order, on every screen, padded with env(safe-area-inset-bottom) so the phone's home indicator does not sit on it |
Rules
- The menu control is named. A
<button>witharia-expandedreflecting its state, and the accessible name "Menu" — as visible text, or asaria-labelon an icon. The three-line hamburger icon is widely read as a menu now, so whether the word is visible is the project's choice. Whether assistive technology can name the control is not (E-34). The menu control works, and shows which way it is. This is behaviour, not markup, and a screenshot cannot show it (
E-116):- Tapping it opens the menu: the links become visible.
aria-expandedis"false"while closed and"true"while open.- While open, its visible label or icon reads as close — the word Close, or a cross — and its accessible name says so. Tapping it again closes the menu.
Escapecloses an open menu and returns focus to the button.<details>/<summary>gives the first three for free; a hand-rolled button has to do each one.Escapeis the one<details>does not give. A few lines add it, and they fit every mode's budget,editorial's included (M-01), because keyboard access a rule requires is not counted:// P-14: Escape closes the open menu and returns focus to its button. addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; const menu = document.activeElement?.closest('details[open]'); if (!menu) return; menu.open = false; menu.querySelector('summary')?.focus(); });
- Where the menu button sits is the project's decision. Top right, top left, centred — that is taste, and it belongs in
DECISIONS.md, not here. What the system asks is only that it stays in the same place on every screen and at every width it appears. The decision is where it sits, never whether it exists: at a width where every destination fits, the table above shows the links and there is no menu button, whateverDECISIONS.mdsays about its position. Mark where the reader is, the same way at every width. Every screen has to answer *where am I?* without the reader remembering how they arrived.
- The link to the current page carries
aria-current="page". A section link whose child page is open may carryaria-current="true". - Style the mark from that attribute —
[aria-current="page"]in CSS — not from a separate.activeor.currentclass. One source for both what is seen and what is announced means the two cannot drift apart; a class alone looks marked and tells a screen reader nothing. - The visible cue is not colour alone (
C-20): weight, an underline or bar, or a filled state. Which one is the project's decision. - Inside an open menu, the current item is marked the same way. When the menu is closed nothing in the navigation is visible, so the page's
<h1>is what tells the reader where they are — every page has one, and it names the page.
- The link to the current page carries
- It works with no JavaScript (
F-41). The links are ordinary links in the page and render visibly by default; script, if there is any, only adds the collapse. Ineditorial, where a page has no script by default (M-01), use<details>with<summary>Menu</summary>— a disclosure the browser provides with no script at all — and the few lines above forEscape. - Never let a row that does not fit scroll sideways. Its last items go past the edge where nobody sees them (
E-62), andeditorialforbids horizontal scrolling on mobile outright. An open menu is a vertical list. - Same destinations, same order, at every width. The phone may show fewer at once. It never shows different ones, and never reorders them —
productfixes navigation position across the app (M-02), and a reader who learned the order on one screen should not have to relearn it on another. - Every item is at least
--size-touch-targettall, made with padding rather than a larger font. The target grows; the text does not. - An open menu does not cover the page unless it has to. If it does cover the page, it is a dialog and
P-07applies: focus moves into it,Escapecloses it, and focus returns to the button. A menu that opens inline needs none of that — butEscapestill closes it and returns focus to the button. In a fixed or sticky header, the open menu lies over the page. It cannot push the page down, so it covers what is under it, and it is still not a dialog: focus is not trapped and the page is not made inert. What it owes the reader instead:
- A tap outside the menu closes it, and that tap is spent closing it. It does not also follow the link or press the button that lay under it, so nothing on the page is activated by accident.
- A control in the header row itself — the theme toggle, a search button, the site name — works on the first tap: the menu closes and the control does its job. Only a tap on the page below is swallowed.
- When the list is taller than the screen below the header, the menu scrolls inside itself, so its last link is reachable without scrolling the page under it.
- These need a few lines of script where
<details>alone is used; they are behaviour this rule requires, counted asM-01countsEscape.
- A sticky header at phone width is one row. Two sticky rows permanently spend a sixth of a phone's height on chrome.
- The wide row appears where the labels fit, not at a device width. Set the breakpoint from the content — the width at which every destination sits on one line at full touch size — so a longer label moves the breakpoint instead of breaking the row.
operator: the wide screen is the real case. At phone width, one Menu button for everything is enough, and keyboard operation of the open menu is mandatory (M-03).
- Kind
- Spec
- Section
- Components and layout