<navbar-elemental>
A site's navigation: a row of links, some of them opening a panel of more links, and the two things a row of links always ends up needing โ somewhere for the ones that do not fit to go, and a way to be a drawer instead on a narrow screen.
It is the APG Disclosure Navigation Menu with one addition the APG has no opinion about, because it is not an accessibility question: the breakpoint is measured rather than declared. Light DOM, no shadow root, nothing you wrote is moved or wrapped.
Drag the window narrower. The links go behind More one at a time as the room runs out, and when the last one goes the bar stops pretending to be a bar and becomes a drawer. Nothing in that sequence is a number anybody typed.
Usage
Edit the sample and the preview above it follows as you type. Narrow it with the width buttons to watch the links move behind More one at a time, and then the whole row become a drawer โ the element is measuring, not guessing, so it answers a real viewport:
<navbar-elemental class="bar" bar-when="(min-width: 40rem)" hover>
<div class="rail">
<ul>
<li><a href="/overview">Overview</a></li>
<li>
<button>Products</button>
<ul>
<li><a href="/cloud">Kestrel Cloud</a></li>
<li><a href="/edge">Kestrel Edge</a></li>
</ul>
</li>
<li><a href="/pricing">Pricing</a></li>
<!-- where the ones that do not fit go. The element fills it. -->
<li data-navbar-more>
<button>More</button>
<ul></ul>
</li>
</ul>
</div>
<button data-navbar-toggle aria-label="Navigation"></button>
</navbar-elemental>
<main>
<p>A page under the header, so an open panel has something to hang over.</p>
</main>
body { margin: 0; padding: 0; font: 1rem/1.5 system-ui, sans-serif; }
/* the preview is as tall as the sample, so the page under the header is what gives an open
panel โ and the drawer โ somewhere to hang */
main { padding: 1rem; min-block-size: 11rem; }
/* The element lays out the row, its panels and its drawer. The bar around them is the page's
โ and on this one it is two rules. Without them the element is a block and its children
stack: the row on one line and the drawer's button under it. */
.bar { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; }
.rail { flex: 1 1 0; }import 'book-of-elementals/navbar';
@use "book-of-elementals/navbar/style.scss";
@use "book-of-elementals/navbar/theme.scss"; // optional look
The shape is most of the API. A list of links; an item that opens a panel is a <button>
with that panel in the same <li>, nested as deep as you like. Two hooks name the parts
structure cannot: the overflow item and the drawer's button.
The list needs a box of its own โ the .rail above. That box is where the element
puts the copy of the row it measures, and it is the only piece of markup this element asks
for that a plain navigation would not already have. It could not be created for you: the
element would have to wrap your list in it, and then every selector you had written
against the list's parent would be pointing at the wrong element.
The row is the first list in the element that nothing else owns. A <ul> inside
another custom element โ a <suggest-elemental> results panel, a nested
<navbar-elemental> โ belongs to that element and is left alone. So a header carrying a
search field and no links of its own upgrades to nothing at all: no rail, no drawer, and
the panel laid out as its own element wrote it.
API
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
bar-when |
string | none | The media query the bar exists in. Outside it, the drawer. Unset means a bar at every width โ until the links stop fitting. |
min-bar-items |
number | 1 |
How many links have to fit for this to still be a bar. 2 says one link beside an overflow button is a drawer instead. |
open |
boolean | false |
Whether the drawer is showing. Reflected, so [open] is a styling hook. |
hover |
boolean | false |
A mouse also opens a panel by pointing at it. Never on touch, never stacked. |
Hooks in the markup
| Attribute | On | Means |
|---|---|---|
data-navbar-more |
the last <li> of the row |
The overflow item. Its <button> is yours to label; its <ul> is filled by the element. |
data-navbar-toggle |
a <button> anywhere in the element |
Opens the drawer while stacked. Hidden while it is a bar. |
data-navbar-stack |
any <li> of the row |
The drawer's alone. Hidden on the bar, and never measured against it. |
All three are optional. Without an overflow item the links simply stop fitting; without a toggle the bar never becomes a drawer; without a stack item the drawer holds exactly what the bar does.
data-navbar-stack is for what a header keeps outside its navigation until there is no
outside left โ a sign-in link, a language picker, the calls to action in
the example. Marked, an item is out of the measurement
in both directions: it is not one of the links competing for room, and no copy of it
reaches the box the row is measured in, so it cannot take width from a bar it is not on.
Properties
| Property | Type | Description |
|---|---|---|
open |
boolean | Gets and sets the open attribute. |
hover |
boolean | Gets and sets the attribute. Reads false while stacked, whatever the markup says. |
stacked |
boolean | Read-only. Whether it is currently the drawer. |
row |
Element | Read-only. The list of links. |
rail |
Element | Read-only. The box it is measured in. |
items |
Element[] | Read-only. The row's items, minus the overflow one. |
toggle |
Element | Read-only. The drawer's button. |
Events
navbar-toggle fires whenever any panel opens or closes โ the drawer included โ and
bubbles.
document.querySelector('navbar-elemental').addEventListener('navbar-toggle', (e) => {
console.log(e.detail.panel, e.detail.open);
});
| Detail | Type | Description |
|---|---|---|
panel |
Element | The list that opened or closed. |
open |
boolean | Which way it went. |
What it writes
On the element:
| Attribute | Value |
|---|---|
data-mode |
bar / stack |
data-overflowing |
Present while some โ not all โ of the links are behind the overflow button |
On the markup:
| Element | Attribute | Value |
|---|---|---|
| every trigger, and the toggle | type |
button, if none was set |
| every trigger, and the toggle | aria-controls |
The id of the list it opens |
| every trigger, and the toggle | aria-expanded |
true / false |
| an item that did not fit | data-overflow |
Present |
| the row's own box | data-navbar-rail |
Present |
| the copy being measured | data-navbar-probe |
Present |
a <span> put inside the toggle |
data-navbar-bars |
Present, aria-hidden โ the middle bar of the hamburger |
No role, anywhere. That is the pattern, not an omission โ see
below. A closed panel carries hidden, and lists without an id
are given one.
The <span> is the one piece of markup the element adds to what you wrote, and it is there
because a hamburger that crosses into an X is three lines while a button has two
pseudo-elements. It is first in the button, so a toggle with a label reads icon-then-label;
it is aria-hidden, because the button's own name already says what the button does; and
nothing is drawn on it without the optional theme.
Keyboard
The APG's table, including the rows it marks optional:
| Key | Does |
|---|---|
| Tab / Shift+Tab | Moves through the bar, and into and through an open panel |
| Enter / Space | Toggles a trigger, or follows a link |
| Escape | Closes the panel focus is in, focus back on its trigger |
| Down / Right | Next item โ or, on a trigger already open, the first item of its panel |
| Up / Left | Previous item |
| Home / End | First / last item |
The arrows do not wrap at the ends, because the pattern's own wording for every one of them is "and it is not the last": off the end of the bar is where the rest of the page is, and Tab is how you get there. Tabbing past the last link of an open panel leaves it โ and closes it on the way out, which is the APG's behaviour and 1.4.13 Content on Hover or Focus's as well.
Stacked, the arrows walk everything on screen from the top of the drawer down, rather than stopping at the edge of a list the reader is looking straight through.
Inside a panel the vertical keys stop scrolling the page, and they keep doing so at the ends of it โ Down on the last item of an open panel does nothing rather than scrolling the page out from under a reader who is still in that panel. On the bar itself they are left alone once there is nowhere to go, because a bar runs the other way and the page is what is below it.
Measuring instead of guessing
The usual way to fold a navigation away is a breakpoint: below 40rem, hamburger. Which is a guess about the one thing the author cannot know โ how many links this site has, how long their labels are in the reader's font, whether that font has even arrived yet. Get it wrong one way and links overlap the search field at 900px; get it wrong the other and a bar with three short links hides them all on a tablet with room to spare.
So the row is measured. An IntersectionObserver reports which items are not entirely
inside it, those items leave the row, and copies of them appear under the overflow button.
The interesting part is what gets observed, and it is not the row:
Hiding an item shrinks the row's contents, which makes the next item fit, which hides that one instead. An observer watching the box it is also changing is an infinite loop that eats a navigation one frame at a time.
So the element clones the row once, drops the clone in the rail beside it, and watches the clone. The clone is never touched again. The row is free to change, because nothing is measuring it โ which is what lets the overflow button sit immediately after the last link that fits, rather than being shoved off the end of the bar by a row full of hidden boxes that still take up space.
Two details of the clone, both about width. Its panels are removed: an absolutely positioned box adds nothing to a row's width, and a copy of one would be a second box answering to the same anchor name. And its overflow item is moved to the front, where the box reserves exactly the room the real one will take at the other end โ the row has to be measured against the space left once the overflow button is on it, or the last link and the button fight over the same pixels.
There is no resize listener, because there is nothing for one to do: an intersection is
recomputed when it changes, whoever changed it. Which includes the case a cached set of
measurements gets wrong forever โ a webfont arriving late changes every label's width, the
clone's geometry changes with it, and the observer says so.
Why it cannot oscillate
Three things move on their own here, and each one only ever pushes in one direction:
- The overflow button. It appears only when something does not fit while it is absent, and its arrival takes room away โ so whatever did not fit still does not. It goes away only when everything fits while it is present, and its departure gives room back โ so everything still fits.
- The drawer. Switching to it hides the row but not the clone, so the measurement survives being collapsed. A bar with nothing left to measure could never come back out.
bar-when. A query cannot oscillate, because nothing the element does changes the width of the window.
Which is also the one trap worth knowing about when you style this thing: do not key
anything that changes the bar's own width off data-mode. Moving a call to action out of
the bar when it goes to stack, say, hands the row a hundred and fifty pixels, which lets
the links fit, which puts the button back, which takes the room away again. That is what
bar-when is for โ see the example, which does exactly
this and explains why.
Two modes
data-mode is which widget the bar is right now, and the element writes it so a
stylesheet reads the mode back off the element rather than repeating the query in bar-when
and drifting from it.
bar |
stack |
|
|---|---|---|
| The row | one line, overflow behind the button | a drawer under the whole element |
| Panels | float over the page, one at a time | in the flow, as many as you opened |
| Sibling panels | close each other | stay open |
| Arrows | walk the current list | walk everything on screen |
hover |
opens a panel | ignored |
| Click outside | closes | leaves it open |
| Closed panels | hidden |
hidden="until-found" |
That last row is the one that looks like an inconsistency and is not. A stacked panel is
hidden with until-found, so find-in-page reaches a link inside a closed drawer, opens it
and scrolls to it. A panel on the bar is hidden with a plain hidden, because
until-found hides a box's contents and keeps the box โ and a box with a border, a
background and a shadow on it and nothing inside is a small empty smudge parked under the
button. The drawer can have both because the element takes the frame off a hidden drawer,
whatever the theme put there, so there is nothing left to leave behind.
Crossing between the modes closes whatever was open, because what was open belonged to the other widget.
Staying on screen
A panel near an edge would open past it. This element leaves that to CSS anchor positioning, in its own stylesheet, with no script involved:
navbar-elemental[data-mode="bar"] [data-navbar-rail] > ul > li {
anchor-scope: --navbar-elemental-branch;
}
navbar-elemental[data-mode="bar"] [data-navbar-rail] > ul > li > button {
anchor-name: --navbar-elemental-branch;
}
navbar-elemental[data-mode="bar"] [data-navbar-rail] > ul > li > ul {
position-anchor: --navbar-elemental-branch;
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}
<menu-elemental> measures this in JavaScript instead, and
the difference is instructive: its submenu carets have to point at the side the panel
actually took, and no CSS selector can ask which fallback won. A caret on a bar points
down whatever happens, so there is nothing to keep in step, so CSS can have it.
Two things about it, both learned the hard way:
- Nothing above a panel may be
position: relative. A positioned ancestor becomes the panel's containing block, and the fallbacks are judged against that box โ one the size of a bar, which every panel overflows and none can escape, so none of the flips ever fire. Which also rules outcontainer-typeon the bar: it brings layout containment, and layout containment is a containing block. anchor-scopeis not optional. Without it every panel resolves the same anchor name and they all stack on the first button.
Where anchor positioning is missing the panels stay at their static position, which is under their own button anyway.
Opening on hover
hover adds the pointer to the ways a panel opens โ and pointing at one panel is also the
instruction to close the others, since they overlap and only one of them can be read at a
time:
<navbar-elemental hover bar-when="(min-width: 40rem)">โฆ</navbar-elemental>
It is an addition and never a replacement: click, Enter and the arrow keys work exactly as before, because a navigation that only opens under a steady hand is one some readers cannot open at all. Four things it deliberately does not do:
- Nothing on touch. A touch "hover" is the tap that was about to choose something, so the pointer type is checked and only a mouse counts.
- Nothing stacked. In the drawer the panels are in the flow, and a pointer crossing the stack on its way somewhere would open every one it passed.
- It does not move focus. The reader's caret stays where they put it, so the arrow keys carry on from there rather than from wherever the mouse happened to be.
- It never closes a panel the keyboard is inside. Closing one out from under the caret leaves focus on a hidden element, which is focus nowhere.
Leaving the bar closes the panels after a beat โ the gap between a label and its panel is somewhere a pointer passes through, not somewhere it means to be.
Why not a menubar
Because these are links to pages. role="menuitem" replaces link semantics: a screen
reader announces "menu item" rather than "link", and the promises that come with a link go
with it. That is the right trade for a command and the wrong one for a page somebody might
want to open in a new tab.
The APG's own Navigation Menubar example opens by talking you out of itself:
The
menubarpattern requires complex functionality that is unnecessary for typical site navigation that is styled to look like a menubar with expandable sections or fly outs. [โฆ] the Disclosure Pattern [โฆ] is better suited for most web sites because few sites need the additional keyboard functionality required to support the ARIAmenubarandmenuroles.
<menu-elemental> is the one for commands โ account menus, editor
toolbars, "more actions" buttons โ and it does write those roles, because there they are
the honest description.
Without JavaScript
The links are authored plainly visible, with every nested list under its own item and every one of them reachable. The drawer's button is hidden until the element upgrades, and the overflow item is hidden until there is something behind it, so scripting turned off leaves a page whose navigation is a list of links โ which is what it was underneath all along.
With scripting on, the stylesheet paints the waiting markup as its best guess at the bar
instead: panels closed, the row a single line, so the expanded tree does not flash and
collapse on the first frame. A guess, because the mode is unknowable until the script
reads bar-when โ a page about to stack shows a row of links for the length of the bundle
fetch, and the overflow folds one frame after upgrade. The line that gate draws is worth
knowing: a bundle that never arrives while scripting is on โ blocked, 404 โ leaves the
panels closed with nothing to open them. The fallback covers scripting turned off, not
every way a script can fail to run.
There is no copy of the row in the markup to go stale, either: the copies are made on upgrade and removed again if the element leaves the document.
The look
The element's own stylesheet places the lists, decides which of them are on screen, and
builds the rail. That is all โ no borders, no colours, no padding, and nothing at all about
the bar around the row: where the logo goes and how wide the search field is are the page's
business, which is what data-mode is for.
@use "book-of-elementals/navbar/style.scss";
The optional theme adds a look: panels, hover states, a caret that points down on the bar
and turns like a disclosure's in the drawer, and a hamburger on the toggle that crosses into
an X while the drawer is open. It is built out of currentcolor and Canvas, so it follows
a page's palette and its theme switch without configuration.
The drawer it draws hangs off the bar rather than floating under it โ no top edge, no top
corners, and a slide-and-fade on the way in and out. It scrolls itself once it is taller
than what is left of the screen (max-block-size: calc(100dvh - 100%), the percentage being
the bar's own height), so a long navigation on a phone ends in a scrollbar rather than
somewhere below the fold. That ceiling is right while the header is at the top of the
viewport, which is where a header that opens a drawer is; halfway down a scrolled page the
drawer is shorter than it needed to be.
Two things a page around it may want back. A toggle holding nothing but the icon is a square sized off the icon; one with a label beside it stays a pill. And if your bar draws its own bottom border, the drawer covers it โ the drawer is positioned against the element's padding box, which is inside that border, so hand the pixel back:
navbar-elemental[data-mode="stack"] .rail > ul:not([data-navbar-probe]) {
margin-block-start: 1px;
}
@use "book-of-elementals/navbar/theme.scss";
| Property | Default |
|---|---|
--navbar-elemental-radius |
0.375rem |
--navbar-elemental-inset |
0.35rem |
--navbar-elemental-gap |
0.15rem |
--navbar-elemental-caret-size |
0.75em |
--navbar-elemental-hamburger-size |
1.25em |
--navbar-elemental-bar-thickness |
2px |
--navbar-elemental-bar-gap |
0.35em |
--navbar-elemental-surface |
Canvas |
--navbar-elemental-hover |
currentcolor at 10% |
--navbar-elemental-border |
currentcolor at 20% |
--navbar-elemental-shadow |
0 4px 20px at 15% |
--navbar-elemental-surface is the one worth re-pointing: a page whose background is not
quite Canvas wants its panels to match the page rather than the browser. Turn it in the
Options tab and copy the rule out of the bottom of the panel:
<navbar-elemental class="bar">
<div class="rail">
<ul>
<li><a href="/overview">Overview</a></li>
<li>
<button>Products</button>
<ul>
<li><a href="/cloud">Kestrel Cloud</a></li>
<li><a href="/edge">Kestrel Edge</a></li>
</ul>
</li>
<li><a href="/pricing">Pricing</a></li>
</ul>
</div>
<button data-navbar-toggle aria-label="Navigation"></button>
</navbar-elemental>
<main>
<p>Turn the knobs above and the header answers.</p>
</main>
body { margin: 0; padding: 0; font: 1rem/1.5 system-ui, sans-serif; }
/* the page under the header, which is what an open panel hangs over */
main { padding: 1rem; min-block-size: 11rem; }
/* the bar is the page's two rules, as everywhere else on this page */
.bar { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; }
.rail { flex: 1 1 0; }What is deliberately not here
- No priority list. The links fold away in DOM order, last one first. Which link a site would rather keep is a thing only that site knows.
- No count on the overflow button. "More (2)" is easy from the same attribute and is a number that changes while you drag a window edge, which is not information.
- No
aria-current. That is the page's to set โ"page"on the link for the page you are on,"true"on the item whose panel holds it โ and nothing here knows which page you are on. - No search, no logo, no buttons. A navbar full of slots for things it does not understand is a framework. Those go beside the row, in the bar, where they are yours; the example is a header built that way.