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
Markup structure
Section titled “Markup structure”<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
popovertargetvalue matches the panel’sid. That single pairing is the entire toggle mechanism - noonclick, noshowPopover()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-expandedis never written into the markup. Oncepopovertargetpoints at a popover, the browser maps the invoker’s expanded state into the accessibility tree on its own.data-site-navon the root is the styling hook. It carries no behaviour; every selector in_navigation.scssand 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.
Why popover="auto" is written out
Section titled “Why popover="auto" is written out”popover is an enumerated attribute with two relevant states, auto and manual. The two defaults differ:
- Missing value default is
auto, so a barepopoverbehaves likepopover="auto". - Invalid value default is
manual. Any value the parser does not recognise -popover="atuo",popover="true",popover="open"- silently becomesmanual.
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.
Styling the open state
Section titled “Styling the open state”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.
Styling the resting state
Section titled “Styling the resting state”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.
Preventing the first-paint flash
Section titled “Preventing the first-paint flash”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:inlineis 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:inlineopts 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.scssare (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-radiusis deliberately absent: with a transparent background and a transparent border there is nothing for a radius to round, so--card-radiusneeds no stand-in at first paint.44pxis hard-coded because--space-11is itself defined in the late stylesheet. - The
@supportsguard keeps the block out of the fallback’s way. Without a popover the button is inert and the fallback hides it withdisplay: 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 inlinedisplay: inline-flexwins 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.
Fallback without the Popover API
Section titled “Fallback without the Popover API”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.
Why each block is written twice
Section titled “Why each block is written twice”@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 is not a security boundary
Section titled “The popover is not a security boundary”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.
Accessibility notes
Section titled “Accessibility notes”- 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>isaria-hidden="true"andfocusable="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
44pxmin-height and min-width. - The panel is offset below the bar so it never covers the
loginslot 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).
Related documentation
Section titled “Related documentation”- Components - the wider component library
- Layouts - how
Base.astrocomposes the navigation - Styling - design tokens used by the navigation styles