Accessibility & semantics

A primary-nav flyout built on `popover`, not `aria-expanded`

A primary-nav flyout built on `popover`, not `aria-expanded`

The hand-rolled primary-nav flyout is one of the oldest accessibility traps on the web. A button, a hidden list of links, and an aria-expanded attribute you have to remember to flip every time the state changes. Miss one path through that state machine and a screen-reader user hears the wrong thing about whether the menu is open.

Adrian Roselli has just published a rebuild of his 2019 disclosure-widget navigation pattern, this time using the native HTML popover attribute in place of the hand-scripted toggle. The result is worth reading closely, because it clarifies which parts of an accessible flyout the platform now carries for you and which parts you still own.

How the pieces sit

Per the source, the pattern uses a link (to the parent page) plus a sibling <button popovertarget> that opens a <ul popover>. The list markup is placed immediately after its trigger, so the focus order and the reading order match. That last point is easy to miss and expensive to get wrong: if the popover content sits elsewhere in the DOM, keyboard users can Tab past the item that was supposedly "underneath" the button they just activated.

<a href="…" aria-current="page">Products</a>
<button popovertarget="products-menu"
        aria-labelledby="…">…</button>
<ul id="products-menu" popover>
  <!-- links -->
</ul>

That link + button + popover triad is the whole shape.

What the platform handles now

The popovertarget attribute wires the button to the popover, and per the source it also gives you the programmatic aria-expanded state for free. No script has to toggle it. Two more behaviours come along for the ride: pressing Esc closes the popover, and a click outside closes it. All three used to be script that authors got wrong.

The only interaction the source still had to write by hand is focusout-to-close, dismissing the popover when focus leaves the whole component. Compared with the old disclosure widget, that is a small residual surface.

Naming the button without duplicating the link

Two elements sit side by side in this pattern: the page link and the button that opens the flyout. Naming that button is where accessibility usually goes wrong. An aria-label="Open Products submenu" on the button duplicates the visible text and, per the source, quietly breaks under auto-translation.

The source sets aria-labelledby on the button to reference the preceding link's text. No duplication, no fragile hidden string, and the browser's translation path stays honest. Current-page marking follows the same source-driven rule: aria-current="page" sits on the current page's link and on the link to its parent page.

The condition attached to the top layer

Here is the constraint that decides whether you can adopt this pattern at all. Popovers render in the top layer. Per the source, the pattern is only safe to reach for if every dialog on the site is already using the native <dialog> element. Mix a top-layer popover with an old position: fixed modal elsewhere and the two will fight over stacking, and users will find the seams.

That is not a footnote. It is a precondition on the whole approach.

Small structural wins on top

A few last details from the source, worth noting because they cost nothing extra:

  • The triggers meet WCAG 2.5.8 Target Size (Minimum). The pattern is not dodging touch reachability.
  • Layout uses logical properties and flexbox with no width media queries.
  • The demo works out of the box in forced-colors mode / Windows High Contrast Mode.
  • The demo relies on cross-browser CSS anchor positioning across Firefox, Chromium and Safari, which the source notes only came about recently. The arrow that visually ties the popover to the button rotates on open via the :popover-open pseudo-class.

What to take to your next PR

The verdict is straightforward: this is what "use the platform" looks like now that the platform has caught up.

For your next primary-nav rebuild:

  • Before you start, check that every dialog on the site is already <dialog>. If not, fix that first.
  • Wire the button with popovertarget and delete your aria-expanded toggle script.
  • Point aria-labelledby at the sibling link's text instead of writing a duplicate label.
  • Apply aria-current="page" to both the current page's link and its parent page's link.
  • Keep the popover content immediately after its trigger in the DOM.
  • Write the focusout-to-close behaviour by hand. The platform does not do that one for you yet.

The old disclosure-widget script is not gone. It is smaller. Take the win.

Source: Adrian Roselli (adrianroselli.com)

Related
Accessibility & semantics

Chrome 150 hands composite-widget arrow keys to the browser

Chrome 150 ships the `focusgroup` HTML attribute, a way to get roving arrow-key focus inside composite widgets like listbox, tablist and toolbar without a line of JavaScript. Adrian Roselli has published script-free demos, and the spec story is still an Open-UI explainer.

July 6, 2026
Accessibility & semantics

Your `<nav>` label already says 'navigation'

Writing 'Primary navigation' on a `<nav>` makes a screen reader announce the word twice. The fix is to trust the landmark and let the label do its actual job.

June 26, 2026
Accessibility & semantics

`headingoffset` is not the outline algorithm coming back

Firefox has shipped `headingoffset` behind a flag. It quietly fixes a real pain point for reusable components, but it is not, and cannot be, the long-abandoned Document Outline Algorithm in disguise.

June 22, 2026