Skip to content
GitHub
Sections

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 widthUse
Every destination fits, each at least --size-touch-targetShow 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 notShow those, plus a button labelled Menu for the rest
Nothing fits beside the site nameA button labelled Menu for all of them
product, three to five top-level sections used repeatedlyA 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> with aria-expanded reflecting its state, and the accessible name "Menu" — as visible text, or as aria-label on 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-expanded is "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.
    • Escape closes 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.
    • Escape is 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, whatever DECISIONS.md says 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 carry aria-current="true".
    • Style the mark from that attribute — [aria-current="page"] in CSS — not from a separate .active or .current class. 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.
  • 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. In editorial, 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 for Escape.
  • Never let a row that does not fit scroll sideways. Its last items go past the edge where nobody sees them (E-62), and editorial forbids 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 — product fixes 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-target tall, 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-07 applies: focus moves into it, Escape closes it, and focus returns to the button. A menu that opens inline needs none of that — but Escape still 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 as M-01 counts Escape.
  • 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

Read as plain text