Skip to content

Navigation Popover

Navigation.astro renders the primary site navigation. The bar itself holds a brand link, a hamburger button and the login slot. The five site links live inside a native HTML popover panel that the button opens.

Open, close, Esc-to-close and outside-click dismissal (“light dismiss”) are provided by the browser through the popover and popovertarget attributes. The component ships no JavaScript: no event listeners, no hydration directive, and no open-state class toggled on the DOM.

Component: src/components/astro/Navigation.astro

Styles: src/styles/components/_navigation.scss, plus a small is:inline block in the component itself - see Preventing the first-paint flash

<nav aria-label="Primary" data-site-nav>
<a href="/" data-nav-brand>Site title</a>
<button type="button" popovertarget="site-nav-popover" aria-label="Menu">
<svg aria-hidden="true" focusable="false"><!-- hamburger --></svg>
</button>
<div id="site-nav-popover" popover="auto">
<ul>
<li><a href="/">Home</a></li>
<!-- Articles, Blog, About, Contact -->
</ul>
<slot />
</div>
<slot name="login" />
</nav>

Four details carry the whole pattern:

  • The button’s popovertarget value matches the panel’s id. That single pairing is the entire toggle mechanism - no onclick, no showPopover() call.
  • The panel carries popover="auto", which tells the browser to keep it hidden until it is invoked, promote it to the top layer when shown, and dismiss it on Esc or an outside click.
  • aria-expanded is never written into the markup. Once popovertarget points at a popover, the browser maps the invoker’s expanded state into the accessibility tree on its own.
  • data-site-nav on the root is the styling hook. It carries no behaviour; every selector in _navigation.scss and in the inline first-paint block is scoped to it. See Why the styles are scoped to [data-site-nav].

All props are optional.

Prop Type Default Description
brandTitle string SITE_TITLE Text of the brand link on the left of the bar.
brandHref string '/' Destination of the brand link.
menuId string 'site-nav-popover' id of the popover panel, wired to the button’s popovertarget.
showBrand boolean true Render the brand link. Set false when a consumer supplies its own.

menuId matters when more than one Navigation renders on a page: id values must stay unique, and a duplicate id sends both buttons to the same panel.

---
import Navigation from '#components/astro/Navigation.astro'
---
<Navigation brandTitle="Docs" brandHref="/guide/" menuId="docs-nav-popover" showBrand={true}>
<p>Rendered inside the popover panel.</p>
<ul slot="login">
<li><button type="button">Sign in</button></li>
</ul>
</Navigation>
Slot Renders
(default) Inside the popover panel, directly below the link list. Only visible while the panel is open.
login In the bar, after the panel. Always visible, regardless of panel state.

The default slot is a behaviour change for library consumers. Navigation is exported from src/components/index.ts and through the ./astro entry in package.json, and content passed to the default slot previously rendered in the bar. It now renders inside the panel. Move anything that must stay permanently visible to the login slot.

src/layouts/Base.astro puts the userId-gated dashboard and profile links in the default slot, so they render inside the panel alongside the site links, and keeps only the Clerk auth control in the login slot, which is why that control stays in the bar while the panel is closed.

Do not put authenticated-only links in the login slot to “keep them handy” — that slot is always visible, so they would sit in the bar on every page. Which links exist at all is decided by the server-side userId check, never by the panel; see Not a security boundary.

popover is an enumerated attribute with two relevant states, auto and manual. The two defaults differ:

  • Missing value default is auto, so a bare popover behaves like popover="auto".
  • Invalid value default is manual. Any value the parser does not recognise - popover="atuo", popover="true", popover="open" - silently becomes manual.

manual popovers have no light dismiss and no Esc close. They also do not close other open popovers. A typo would therefore leave a panel that opens on click, ignores Esc, ignores clicks elsewhere on the page, and can only be closed by clicking the hamburger a second time - with no error in the console to explain it.

Writing popover="auto" explicitly states the intent at the call site and makes the difference reviewable in a diff. Do not shorten it to a bare popover.

Why the styles are scoped to [data-site-nav]

Section titled “Why the styles are scoped to [data-site-nav]”

Navigation is exported from src/components/index.ts and through the ./astro entry in package.json, so its stylesheet ships to consumers. Selectors like nav:has(> [popover]) and nav > button[popovertarget] describe a shape, not this component - any popover-based navigation on the page matches them. A consumer with its own hamburger menu would silently inherit this one’s 44px box, transparent border and fixed-position panel.

Navigation.astro therefore marks its root:

<nav aria-label="Primary" data-site-nav>
<!-- hamburger button, brand link, popover panel, login slot -->
</nav>

and every rule in _navigation.scss and in the inline first-paint block is prefixed with it. The attribute is a styling hook only - no script reads it, and the E2E suite targets nav[aria-label="Primary"] instead, so the two concerns stay independent.

The marker adds (0,1,0) to every selector on both sides, so all the precedence relationships documented below are unchanged - only the absolute numbers move.

There is no JavaScript, so nothing adds a class when the panel opens. The only hook is the :popover-open pseudo-class combined with :has():

nav[data-site-nav]:has(> [popover]:popover-open) button[popovertarget] {
background-color: color-mix(in srgb, currentcolor 12%, transparent);
border-color: currentcolor;
}

:popover-open matches the panel while it is showing. :has() walks back up to the nav, and the descendant combinator then reaches the button. The detour through the parent is required because CSS has no previous-sibling combinator, and the button precedes the panel in source order.

[aria-expanded] does not work as a selector

Section titled “[aria-expanded] does not work as a selector”

The obvious-looking alternative fails:

// Never matches. Do not use.
nav[data-site-nav] button[popovertarget][aria-expanded='true'] {
border-color: currentcolor;
}

On a popover invoker, the expanded state is an implicit accessibility-tree mapping computed by the browser, not a serialized DOM attribute. The specification defines the invoker’s exposed expanded state, but nothing writes an aria-expanded attribute into the markup. CSS attribute selectors only match serialized attributes, so [aria-expanded='true'] never matches - the same reason getAttribute('aria-expanded') returns null while a screen reader still announces the button as expanded.

This is also why the component does not hand-write aria-expanded. Doing so would create a second source of truth that only stays correct if JavaScript keeps it in sync, which reintroduces exactly the script the popover pattern removes.

At rest the button is a bare icon. The border is not removed - it is kept at 1px solid transparent and only its colour changes:

nav[data-site-nav]:has(> [popover]) > button[popovertarget] {
background-color: transparent;
border: 1px solid transparent;
&:hover,
&:focus-visible {
border-color: color-mix(in srgb, currentcolor 30%, transparent);
}
}

Keeping the border width at 1px in every state is what stops the icon shifting a pixel on hover; border: 0 would change the box. :focus-visible rides along with :hover so keyboard users get the same affordance - the browser outline is still the actual focus indicator.

The tint is mixed from currentcolor rather than a palette token, for the same reason the open-state rule above does it: the button follows whatever colour the surrounding surface sets, so it cannot drift from the bar it sits on.

Base.astro imports the stylesheets in its frontmatter. In a production build Astro emits them as a render-blocking <link rel="stylesheet"> in <head>, but the dev server hands them to Vite, which injects them as <style> tags from JavaScript after the HTML is parsed. For one frame the browser therefore paints the button with the user-agent’s native chrome:

Frame Box Background Border
First paint, unfixed 40x33 rgb(239,239,239) 2px black
After the stylesheet 44x44 transparent 1px transparent

That is a grey bevelled button that then grows by 4px in width and 11px in height - visible as a flash on every dev reload. The values above were read with getComputedStyle in Chromium on macOS; the exact user-agent numbers vary by browser and platform, so treat them as an illustration of the jump rather than fixed constants. Navigation.astro answers it with an is:inline style block:

<style is:inline>
@supports selector(:popover-open) {
nav[data-site-nav] > button[popovertarget] {
align-items: center;
appearance: none;
background: transparent;
border: 1px solid transparent;
color: inherit;
display: inline-flex;
justify-content: center;
min-height: 44px;
min-width: 44px;
padding: 0;
}
}
</style>

Four things make this work:

  • is:inline is the only style directive Astro leaves in the HTML. A plain <style> block in a component is collected into the bundle and, in dev, injected by JavaScript like every other stylesheet - so it would arrive too late to help. is:inline opts out of bundling, scoping and processing, and the rules apply while the document is still parsing.
  • Specificity keeps the duplication safe. The inline selector nav[data-site-nav] > button[popovertarget] is (0,2,2) - one for [data-site-nav], one for [popovertarget], two type selectors. The rules in _navigation.scss are (0,3,2): the same two attributes plus :has(> [popover]), because :has() contributes its argument’s specificity even though the pseudo-class itself does not count. Hover, focus and open states therefore still win, and the inline block only ever governs the frame before the stylesheet lands.
  • Only declarations that change the painted box are repeated. border-radius is deliberately absent: with a transparent background and a transparent border there is nothing for a radius to round, so --card-radius needs no stand-in at first paint. 44px is hard-coded because --space-11 is itself defined in the late stylesheet.
  • The @supports guard keeps the block out of the fallback’s way. Without a popover the button is inert and the fallback hides it with display: none. The fallback’s bare selector is (0,2,2) - an exact tie with this block - and this block sits in <body> while the stylesheet is linked from <head>, so on a tie the inline display: inline-flex wins and the inert button reappears. The guard removes the tie from the picture entirely: on an engine with no popover, this block simply does not apply.

A CSS fade-in cannot solve this. The animation would have to be declared in the same stylesheet whose arrival is late, so it could only begin after the flash had already been painted. Anything that fixes a first-paint problem has to be in the HTML.

Engines that do not implement the Popover API do not hide [popover] elements at all: the attribute is unknown, so the panel would paint as a permanently visible fixed overlay next to a button that does nothing. A feature query handles that case:

@supports not selector(:popover-open) {
// Bare form: engines with neither `:has()` nor popover.
nav[data-site-nav] > button[popovertarget] {
display: none;
}
// `:has()` form: matches the (0,3,2) rules above so it wins on source order.
nav[data-site-nav]:has(> [popover]) > button[popovertarget] {
display: none;
}
// The panel and its list follow the same pairing: strip the chrome and
// positioning, then repeat the block with the `:has()` root.
nav[data-site-nav] > [popover] {
background-color: transparent;
border: 0;
box-shadow: none;
position: static;
}
}

The block hides the inert hamburger button and returns the panel to normal flow, so the links render as a static inline row in the bar. The result is plain, always-visible navigation rather than a broken overlay.

selector(:popover-open) is the right probe here. It tests support for the pseudo-class the open-state styling depends on, which is the feature that actually has to be present, rather than testing the popover attribute indirectly.

@supports gates whether a block applies, not how specific it is. Inside the feature query, the bare nav[data-site-nav] > button[popovertarget] is still only (0,2,2), which loses to the (0,3,2) main rule above it - the fallback would parse fine and do nothing. Repeating each block with the :has() root produces a (0,3,2) tie that wins on source order.

The bare form is not redundant. It is the only rule that reaches an engine supporting neither :has() nor popover, where the main block never matches either.

Why they are separate rules, not one selector list

Section titled “Why they are separate rules, not one selector list”

The two forms must not be combined into a comma-separated list:

// Wrong. On an engine without `:has()`, this whole rule is discarded.
nav[data-site-nav] > button[popovertarget],
nav[data-site-nav]:has(> [popover]) > button[popovertarget] {
display: none;
}

A CSS selector list is unforgiving: if a parser cannot understand any one selector in the list, it drops the entire rule rather than the offending selector. :has() is not a forgiving-list pseudo-class - :is() and :where() are the ones that swallow unknown selectors, and while :has()’s own argument list is forgiving, the outer list that contains it is not.

So on exactly the engines the bare selector was written for - no :has(), no popover - the list form would take that selector down with the :has() one, and the fallback would silently do nothing. Splitting the list is what makes the bare form actually reach those engines.

The popover controls visibility only. Everything inside [popover], including anything passed through the default slot, is present in the HTML response whether the panel is open or not. It is visible in View Source, in a curl of the page, and to any crawler.

Never place authenticated, role-gated or otherwise sensitive content inside the panel and treat the closed state as protection. Gate protected content on the server instead - middleware, Astro.locals.userId, or the role guards.

See the Page-Level Protection guide and the Role Guard guide for the supported approaches.

  • The bar is a <nav aria-label="Primary"> landmark, so assistive technology can jump straight to it.
  • The hamburger button carries aria-label="Menu"; its <svg> is aria-hidden="true" and focusable="false" so it is not announced or reachable by keyboard.
  • The button meets the WCAG 2.2 target-size minimum (SC 2.5.8) through a 44px min-height and min-width.
  • The panel is offset below the bar so it never covers the login slot while that slot is in the focus path (SC 2.4.11, Focus Not Obscured).
  • The panel width is clamped so it cannot force horizontal scrolling at 320px (SC 1.4.10, Reflow).
  • The open transition is wrapped in @media (prefers-reduced-motion: no-preference).
  • Components - the wider component library
  • Layouts - how Base.astro composes the navigation
  • Styling - design tokens used by the navigation styles