<menu-elemental>
A <button> and the nested lists it opens, per the
APG Menu Button pattern โ plus
one thing the APG has no opinion about, because it is a layout question: below a
breakpoint the whole thing stops being a menu. Light DOM, no shadow root, nothing
moved or wrapped.
Open it with the mouse, then try it with the keyboard: Down to open, arrows to move, Right into a submenu, Left back out, Escape to close, or type the first letters of an item. Then narrow the window past 40rem and try it again โ it is a different widget down there, on the same markup.
Usage
Edit the sample and the preview above it follows as you type. The width buttons are the
point here โ flyout-when is what decides whether this is a flyout or a stack of disclosures,
and 375px is the width where it stops being a menu:
<menu-elemental flyout-when="(min-width: 40rem)">
<button>Account</button>
<ul>
<li><a href="/profile/">Profile</a></li>
<li>
<button>Preferences</button>
<ul>
<li><a href="/preferences/theme/">Theme</a></li>
<li><a href="/preferences/language/">Language</a></li>
</ul>
</li>
<li><a href="/sign-out/">Sign out</a></li>
</ul>
</menu-elemental>
<main></main>
body { margin: 0; padding: 1rem; font: 1rem/1.5 system-ui, sans-serif; }
/* the preview is as tall as the sample, and a menu opens over the page rather than pushing
it - so the page under the button is what an open panel has to hang over */
main { min-block-size: 11rem; }
/* a rim, so the trigger reads as something you press. The theme leaves it flat on purpose:
a menu button is a word in a menubar - File, Edit - as often as it is a button */
menu-elemental > button {
border: 1px solid color-mix(in srgb, currentcolor 25%, transparent);
}import 'book-of-elementals/menu';
@use "book-of-elementals/menu/style.scss";
@use "book-of-elementals/menu/theme.scss"; // optional look
The shape is the whole API. A <button> and a list beside it; an item that opens a
submenu is a <button> with that submenu in the same <li>. Nest as deep as you
like โ every level is wired the same way.
API
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
flyout-when |
string | none | The query the flyout exists in โ a media query, or container: and a container condition. Outside it, nested disclosures. Unset means a menu at every width. |
open |
boolean | false |
Whether the root list is showing. Reflected, so [open] is a styling hook. |
hover |
boolean | false |
A mouse also opens it by pointing at it. Never on touch, never inline. |
Properties
| Property | Type | Description |
|---|---|---|
open |
boolean | Gets and sets the open attribute. |
inline |
boolean | Read-only. Whether it is currently the stack, not the flyout. |
hover |
boolean | Gets and sets the attribute. Reads false while inline, whatever the markup says. |
button |
Element | Read-only. The root <button>. |
menu |
Element | Read-only. The root list. |
Events
menu-toggle fires on the element whenever any list opens or closes โ the root one
and every submenu โ and bubbles.
document.querySelector('menu-elemental').addEventListener('menu-toggle', (e) => {
console.log(e.detail.menu, e.detail.open);
});
| Detail | Type | Description |
|---|---|---|
menu |
Element | The list that opened or closed. |
open |
boolean | Which way it went. |
What it writes
On the button, and on every submenu trigger:
| Attribute | Value |
|---|---|
type |
button, if none was set |
aria-controls |
The id of the list it opens |
aria-expanded |
true / false |
aria-haspopup |
menu โ flyout mode only |
On the lists and their items, flyout mode only:
| Element | Attribute | Value |
|---|---|---|
| every list | role |
menu |
every <li> |
role |
none |
| every item | role |
menuitem |
| every item | tabindex |
-1 |
A closed list carries hidden. Lists without an id are given one.
Keyboard
On the button:
| Key | Does |
|---|---|
| Enter / Space | Opens, focus on the first item |
| Down | Opens, focus on the first item |
| Up | Opens, focus on the last item |
In an open menu:
| Key | Does |
|---|---|
| Down / Up | Moves between items, wrapping at both ends |
| Home / End | First / last item |
| Right | Opens the item's submenu, focus on its first item |
| Left | Closes the submenu, focus back on the item that opened it |
| Escape | Closes the list focus is in, focus back on its trigger |
| Enter / Space | Activates the item |
| Any letter | Jumps to the next item starting with it; keep typing to narrow |
| Tab | Leaves, and closes on the way out |
Tab is not trapped, here or anywhere in this book. A menu is laid over the
page rather than replacing it, nothing behind it is inert, and a keyboard visitor
who cannot tab out of a dropdown is stuck on your page, not in your menu.
Inline, the letter keys, Left and Right go back to the browser โ see below.
Styling hooks
| Selector | Matches |
|---|---|
menu-elemental[open] |
While the root list is showing |
menu-elemental[data-mode="flyout"] |
While it is a menu |
menu-elemental[data-mode="inline"] |
While it is a stack of disclosures |
[aria-expanded="true"] |
An open trigger, at any level |
[hidden] |
A closed list |
The element writes data-mode itself, so a stylesheet reads the breakpoint back off
the element rather than repeating the query in flyout-when and drifting from it. It writes
data-side and data-align on the lists for the same reason.
Two modes
A flyout is a desktop object. It floats over the page, one branch of it is open at a time, and the arrow keys move between items because the items are not in the tab order. None of that survives being 380px wide. There, the same markup wants to be a stack of nested disclosures in a drawer: links you tab through, submenus that stay open where you left them, no floating anything.
flyout-when is which is which. Inside the query, the flyout. Outside it, the stack.
<menu-elemental flyout-when="(min-width: 60rem)">โฆ</menu-elemental>
A menu that is not the page's width asks about its own box instead. One in a sidebar, a
card or a rail is 380px wide on a desktop the media query calls wide, and it gets a flyout
that has nowhere to float. container: in front hands the same condition to the nearest
ancestor container
โ a container name goes before the parenthesis, exactly where @container puts it:
<div style="container-type: inline-size">
<menu-elemental flyout-when="container:(min-width: 30rem)">โฆ</menu-elemental>
</div>
Everything else is the plain-query behaviour: same crossing, same roles, same close.
How the container is measured is
written up under <disclosure-elemental>, which takes the same two rulers.
What changes is not only the CSS. role="menu" is a promise that the arrows work and
Tab does not, so inline the roles come off entirely โ the items go back to
being links in a list, tabbable, announced as links, and the triggers stay
aria-expanded buttons, which is to say disclosures. Making the menu promise while
the items are a plain tabbable list would be a lie told to exactly the readers who
cannot see the layout that makes it obvious.
The rest of the differences follow from that:
| Flyout | Inline | |
|---|---|---|
| Roles | menu / menuitem |
none |
| Tab order | The button only | Every item |
| Sibling branches | Close each other | Stay open |
| Arrows | Walk the current list | Walk everything on screen |
| Letters | Type-ahead | The browser's |
| Left / Right | Open / close a branch | The browser's |
| Opening | Moves focus to the first item | Leaves focus alone |
| Click outside | Closes | Leaves it open |
Crossing the breakpoint closes whatever was open, because the thing that was open belonged to the other widget.
Staying on screen
A flyout near an edge would open past it, so the element measures before it paints and
writes where the list went. The stylesheet does the positioning from there โ the same
trade as data-mode:
| Attribute | On a list | Values |
|---|---|---|
data-side |
the root | block-end (under the button) / block-start (over it) |
data-side |
a submenu | inline-end (beside it) / inline-start (the other side) |
data-align |
either | start / end โ which way it runs from there |
One decision per axis, which is how a button in the bottom right corner ends up with a menu that opens upward and a submenu that opens up and to the left. The preferred placement wins when neither fits, so a panel with nowhere good to go still lands where the reader expects it.
/* your own panel, on the two attributes the element writes */
menu-elemental > ul[data-side="block-start"] {
box-shadow: 0 -4px 20px rgb(0 0 0 / 15%);
}
Here is the corner case, live. The button is against the right edge, so the panel runs back from it and the submenu opens on the side there is room for โ open Preferences and watch which way it goes:
<div class="bar">
<menu-elemental>
<button>Account</button>
<ul>
<li><a href="/profile/">Profile</a></li>
<li>
<button>Preferences</button>
<ul>
<li><a href="/preferences/theme/">Theme</a></li>
<li><a href="/preferences/language/">Language</a></li>
</ul>
</li>
<li><a href="/sign-out/">Sign out</a></li>
</ul>
</menu-elemental>
</div>
<main></main>
body { margin: 0; padding: 1rem; font: 1rem/1.5 system-ui, sans-serif; }
main { min-block-size: 11rem; }
/* the whole of the setup: the button is where a header keeps its account menu, and there is
no room to its right for either panel to run into */
.bar { display: flex; justify-content: flex-end; }
menu-elemental > button {
border: 1px solid color-mix(in srgb, currentcolor 25%, transparent);
}Nothing in the markup says "open leftward". The element measured the button against the
viewport, wrote data-align="end" on the root list and data-side="inline-start" on the
submenu, and the stylesheet did the rest โ which is also why the submenu's caret points the
way it opened.
The theme reads them too: a submenu that had to open on the inline start says so with a
caret pointing that way, which is the reason this is measured in the element rather than
left to CSS position-try-fallbacks โ nothing in CSS can select the fallback that won.
Placement is decided when a list opens and again on resize. Not on scroll: a page
scrolling under an open menu is the page moving out from under someone in the middle of
using it, and re-placing every frame of that costs more than it fixes. It is skipped
entirely inline, where the lists are in the flow and there is nothing to collide with.
Opening on hover
hover adds the pointer to the ways it opens โ the root list from the button, a branch
from its own item, and a branch closed again by pointing at any other item of the same
list:
<menu-elemental hover flyout-when="(min-width: 40rem)">โฆ</menu-elemental>
It is an addition and never a replacement: click, Enter and the arrow keys open it exactly as before, because a menu that only opens under a steady hand is a menu some readers cannot open at all. Three 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 inline. Below the breakpoint the branches are stacked in the page, and a
pointer crossing the stack on its way somewhere would open every one it passed.
hoverreadsfalsethere whatever the markup says. - It does not move focus. Opening by click puts focus on the first item; opening by hover leaves the reader's focus where they put it, so the arrow keys carry on from there rather than from wherever the mouse happened to be.
Leaving the element closes it after a beat โ the gap between a button and its panel is somewhere a pointer passes through, not somewhere it means to be โ and not at all if the keyboard is still inside it.
Menu, or navigation?
This element is for commands โ things that act on the page or the app. Account menus, editor toolbars, "more actions" buttons, sort and filter pickers.
For a site's own navigation โ a row of links to pages, with panels of more links under
some of them โ <navbar-elemental> is the element, and it writes no
roles at all.
The reason is role="menuitem". It replaces the link semantics โ a screen reader
announces "menu item", not "link", and the promises that come with a link go with it.
Which is the right trade for a command and the wrong one for a page you might want to
open in a new tab. The APG says so itself: its
disclosure navigation
example is the pattern for site navigation, and the navbar is that pattern.
For a single panel of links rather than a bar of them, there is a smaller answer still โ
<disclosure-elemental>, or nothing but the native
popover attribute, which
covers dismissal, focus return and the tab order with no script at all:
<button popovertarget="site-nav">Menu</button>
<ul id="site-nav" popover>
<li><a href="/docs/">Docs</a></li>
</ul>
If your nav is genuinely menu-shaped โ an application's own menu bar, with commands in it โ this element is the right one and the roles are the honest description.
Without JavaScript
The submenus are hidden by the element, not by the markup, and the stylesheet splits the
time before the element is defined on @media (scripting). Scripting off: the button is
hidden and what is left is a nested list of links with every one of them reachable, and
no button that does nothing. Scripting on: the lists are hidden and the button shows, so
the page paints the closed menu from the first frame and upgrading changes nothing on
screen โ no flash of the expanded tree while the bundle loads.
The line that gate draws is worth knowing: a bundle that never arrives while scripting is on โ blocked, 404 โ leaves a button that opens nothing. The fallback covers scripting turned off, not every way a script can fail to run.
Which is also why you should not author hidden on the lists yourself.
The look
The element's own stylesheet places the lists and hides the closed ones. That is all it does โ no borders, no colors, no padding.
@use "book-of-elementals/menu/style.scss";
The optional theme adds a look: a panel, hover states, a caret on anything that opens
a submenu โ pointing sideways in the flyout, turning down in the stack. It is built
out of currentcolor and Canvas, so it follows a page's palette and its theme
switch without configuration.
@use "book-of-elementals/menu/theme.scss";
| Property | Default |
|---|---|
--menu-elemental-radius |
0.375rem |
--menu-elemental-inset |
0.35rem |
--menu-elemental-caret-size |
0.75em |
--menu-elemental-hamburger-size |
1.25em |
--menu-elemental-surface |
Canvas |
--menu-elemental-hover |
currentcolor at 10% |
--menu-elemental-border |
currentcolor at 20% |
Turn them in the Options tab and copy the rule out of the bottom of the panel โ the same
table, with the values live. --menu-elemental-surface is the one to try first: Canvas is
the page's own background, and it is what to re-point when the menu opens over a card:
<menu-elemental>
<button>Account</button>
<ul>
<li><a href="/profile/">Profile</a></li>
<li><a href="/settings/">Settings</a></li>
<li><a href="/sign-out/">Sign out</a></li>
</ul>
</menu-elemental>
<main></main>
body { margin: 0; padding: 1rem; font: 1rem/1.5 system-ui, sans-serif; }
/* the preview is as tall as the sample, and a menu opens over the page rather than pushing
it - so the page under the button is what an open panel has to hang over */
main { min-block-size: 11rem; }
/* a rim, so the trigger reads as something you press. The theme leaves it flat on purpose:
a menu button is a word in a menubar - File, Edit - as often as it is a button */
menu-elemental > button {
border: 1px solid color-mix(in srgb, currentcolor 25%, transparent);
}The hamburger
class="hamburger" puts an
Octicon three-bars before the
button's label, masked rather than painted so it takes the button's own colour and
follows a theme switch:
<menu-elemental class="hamburger">
<button>Menu</button>
โฆ
</menu-elemental>
<!-- icon only: the name has to come from somewhere, so aria-label -->
<menu-elemental class="hamburger">
<button aria-label="Menu"></button>
โฆ
</menu-elemental>
Opt-in, because a menu button is not always a hamburger โ "Account" and "File" are words, and three bars stand for a whole navigation. Your own icon is the same one rule, and has to be a mask for the same reason:
menu-elemental.hamburger > button::before {
mask-image: url("my-icon.svg"); /* not background-image */
}
--menu-elemental-surface is the one worth re-pointing: a page whose background is
not quite Canvas wants the panel to match the page rather than the browser.
menu-elemental {
--menu-elemental-surface: var(--my-page-background);
}
Layout
The element is display: inline-block and position: relative, because the flyout is
positioned against it. Inline, it drops to display: block and position: static, so
it stretches in a drawer like the block it is.