Menu
.menu is a native popover. popover on the panel and
popovertarget on the trigger is the entire wiring — no JavaScript, no
click-outside handler, no z-index. The platform supplies light dismiss, the top layer,
Escape, and closing any other open popover; CSS anchor positioning supplies the
placement.
When to use it
Use a menu for a list of actions attached to a control: an overflow menu, a row's actions, a set of options too long for a row of buttons. It is not modal — the page behind stays live, and clicking anywhere outside closes it.
<button class="btn" popovertarget="dx-menu">Actions</button>
<div class="menu" popover id="dx-menu">
<div class="menu-label">This export</div>
<button class="menu-item">Download CSV</button>
<button class="menu-item">Duplicate</button>
<button class="menu-item">Share<span class="push">⌘S</span></button>
<div class="menu-sep"></div>
<button class="menu-item menu-item-danger">Delete</button>
</div>
<button class="btn" popovertarget="dx-menu">Actions</button>
<div class="menu" popover id="dx-menu">
<div class="menu-label">This export</div>
<button class="menu-item">Download CSV</button>
<button class="menu-item">Duplicate</button>
<button class="menu-item">Share<span class="push">⌘S</span></button>
<div class="menu-sep"></div>
<button class="menu-item menu-item-danger">Delete</button>
</div>
popovertarget takes the panel's id. The button needs no
type, no handler and no aria-expanded — the browser sets that itself.
Positioning
Placing a floating panel used to mean getBoundingClientRect, a scroll
listener, a resize listener, and logic to flip it when it hits the bottom of the
window. That is what Floating UI exists to do. CSS anchor positioning does it
natively, on the compositor, with no listeners at all — and
src/25-anchor.css is where Deck uses it.
Naming the pair
An anchor needs a name, and each trigger/panel pair needs a unique one. Rather than
make you invent them, deck.js walks every
[popovertarget], generates a name, and sets it on both elements:
trigger.classList.add('anchor');
trigger.style.setProperty('--anchor', name);
panel.style.setProperty('--anchor', name);
Set --anchor yourself on the panel and Deck uses yours instead. The
whole routine is skipped where anchor-name is unsupported, so it costs
nothing there.
The fallback chain
position-area: block-end span-inline-end;
position-try-fallbacks:
block-end span-inline-start,
block-start span-inline-end,
block-start span-inline-start,
flip-block, flip-inline;
position-try-order: most-block-size;
Read it as: prefer below and running toward the end edge; if that does not fit, try
below running the other way, then above, then above the other way. If none fits,
flip. position-try-order: most-block-size picks the option with the
most vertical room rather than the first that merely fits — so a menu near the
bottom of a window opens upward instead of opening downward and scrolling.
Every one of these is a logical keyword. span-inline-end means toward
the end of the inline axis, so the menu aligns to the right of its trigger in
English and the left in Arabic without a second rule.
Sizing from the anchor
anchor-size() lets the panel read its trigger's dimensions.
.menu uses it for a floor —
min-inline-size: max(12rem, anchor-size(inline)) — so a menu is never
narrower than the button it belongs to.
.menu-match goes further and makes the width equal to the
anchor's, which is what a dropdown attached to a form field should do.
<div class="field" style="max-inline-size:22rem">
<span class="label" id="dx-menu-match-l">Assign to</span>
<button class="btn btn-block" popovertarget="dx-menu-match" aria-labelledby="dx-menu-match-l">Choose a person</button>
<div class="menu menu-match" popover id="dx-menu-match">
<button class="menu-item">Ada Chen</button>
<button class="menu-item">Marco Silva</button>
<button class="menu-item">Priya Raman</button>
</div>
</div>
<div class="field" style="max-inline-size:22rem">
<span class="label" id="dx-menu-match-l">Assign to</span>
<button class="btn btn-block" popovertarget="dx-menu-match" aria-labelledby="dx-menu-match-l">Choose a person</button>
<div class="menu menu-match" popover id="dx-menu-match">
<button class="menu-item">Ada Chen</button>
<button class="menu-item">Marco Silva</button>
<button class="menu-item">Priya Raman</button>
</div>
</div>
Where anchor positioning is missing
The whole block is inside @supports (anchor-name: --a). Without
support, .menu keeps its position: absolute from
07-components.css and Deck's adapter — or Floating UI, if you have
loaded it — places it. Neither is required, and nothing breaks: the menu appears,
just without the flipping.
Items, separators and labels
.menu-item is a full-width flex row,
min-block-size: 40px, that works equally on a
<button> or an <a> —
color: inherit and text-decoration: none are set so a link
needs no reset. .push inside one moves a keyboard shortcut to the end and
fades it.
.menu-sep is a one-pixel divider, .menu-label a small
uppercase heading, and .menu-item-danger a destructive item in the bad
colour with a matching hover.
<button class="btn" popovertarget="dx-menu-parts">All the parts</button>
<div class="menu" popover id="dx-menu-parts">
<div class="menu-label">Export</div>
<a class="menu-item" href="#parts">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#download"></use></svg>
Download<span class="push">⌘D</span>
</a>
<button class="menu-item">Duplicate<span class="push">⌘D</span></button>
<div class="menu-sep"></div>
<div class="menu-label">Danger</div>
<button class="menu-item menu-item-danger">Delete for everyone</button>
</div>
<button class="btn" popovertarget="dx-menu-parts">All the parts</button>
<div class="menu" popover id="dx-menu-parts">
<div class="menu-label">Export</div>
<a class="menu-item" href="#parts">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#download"></use></svg>
Download<span class="push">⌘D</span>
</a>
<button class="menu-item">Duplicate<span class="push">⌘D</span></button>
<div class="menu-sep"></div>
<div class="menu-label">Danger</div>
<button class="menu-item menu-item-danger">Delete for everyone</button>
</div>
min-block-size: 40px is below the
44px touch target. That is a deliberate
density trade for a menu that may hold eight items, and it is one of the two places
Deck knowingly ships a smaller target — the other is the data grid's selection
checkbox. Recorded in FINDINGS.md.
Opening
Same mechanism as modal:
:popover-open for the open state, @starting-style for the
frame to animate from, and overlay plus display in the
transition with allow-discrete so it stays rendered while it fades out.
The menu fades and rises six pixels.
.menu::backdrop is set to transparent rather than left
alone. A popover's backdrop exists and captures nothing, but giving it an explicit
transparent background stops a browser default from tinting the page.
Classes
Generated from the stylesheet source by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.menu |
deck.components |
A dropdown built on the native popover attribute: light dismiss, Escape, the top layer and one-at-a-time behaviour all come from the platform. 25-anchor.css positions it against its trigger with anchor positioning and a fallback chain, so it flips when there is no room below. | 07-components.css:620 |
.menu-item |
deck.components |
One row in a menu. Works on a button or an anchor — colour and text-decoration are reset so a link needs no help. Hover and focus share a style, so a keyboard user sees what a mouse user sees. 40px tall, which is under the 44px target: a deliberate density trade for a list of eight. | 07-components.css:647 |
.menu-item-danger |
deck.components |
A destructive item. Red is not announced, so the label has to say what it does and a confirmation should stand behind it. | 07-components.css:665 |
.menu-label |
deck.components |
A small heading above a group of items. Styling only — a screen reader hears ordinary text. | 07-components.css:672 |
.menu-match |
deck.components |
A dropdown that should match the width of the field it belongs to | 25-anchor.css:50 |
.menu-sep |
deck.components |
A one-pixel divider between groups of items. | 07-components.css:669 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--surface-hover |
light-dark(var(--ink-50), var(--ink-900)) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--text |
light-dark(var(--ink-900), var(--ink-100)) |
|
--text-faint |
light-dark(var(--ink-500), var(--ink-500)) |
|
--text-sm |
.8125rem |
|
--text-xs |
.75rem |
|
--bad-100 |
oklch(94.5% calc(var(--chroma-status) * .3) var(--hue-bad)) |
|
--bad-700 |
oklch(44% calc(var(--chroma-status) * 1.1) var(--hue-bad)) |
|
--r-md |
12px |
|
--r-xs |
4px |
|
--shadow-4 |
0 8px 16px oklch(20% .03 var(--shadow-color) / .08), 0 24px 56px oklch(20% .03 var(--shadow-color) / .14) |
|
--space-1 |
.25rem |
|
--space-2 |
.5rem |
|
--space-3 |
.75rem |
|
--space-8 |
2rem |
|
--dur-1 |
110ms |
|
--dur-2 |
190ms |
Accessibility
-
The popover attribute does a lot for free. Light dismiss, Escape,
closing when another popover opens, and
aria-expandedon the trigger are all the browser's. It also puts the panel in the top layer, so it escapes anyoverflow: hiddenancestor — the bug that breaks most hand-rolled dropdowns inside a modal or a table. -
Deck adds no
role="menu", and that is deliberate. The ARIA menu pattern requires rovingtabindex, arrow-key navigation, Home and End, and typeahead. Arole="menu"with none of that is worse than no role: it tells a screen reader to expect behaviour that is not there. Deck's menu is a group of buttons and links, and it is announced as exactly that. - Arrow keys therefore do not move between items. Tab does. For most overflow menus that is acceptable and honest. If you need the real menu pattern, add the roles and the key handling together — never one without the other.
-
Focus is not moved into the panel automatically. A popover does not
trap or move focus. Adding
autofocusto the first item is usually what a keyboard user expects from a menu. - Touch target is 40px, not 44px. See the note under Items.
-
Hover and focus share a style.
.menu-item:hover, .menu-item:focus-visible— so a keyboard user sees the same highlight a mouse user does, which is not true of.list-row. -
A destructive item needs more than a colour.
.menu-item-dangeris red, and red is not announced. Say "Delete" in the label, and put a confirmation behind it. -
Keyboard shortcut hints are decorative text.
<span class="push">⌘S</span>is read aloud as a symbol. Wrap it inaria-hidden="true"if the noise is worse than the information.
Right to left
Everything positional here is logical: position-area uses
span-inline-end, the item padding is padding-inline, and
.push is margin-inline-start. The menu aligns to the
opposite edge of its trigger under dir="rtl" with no extra rules, and the
fallback chain flips with it.
<div dir="rtl">
<button class="btn" popovertarget="dx-menu-rtl">إجراءات</button>
<div class="menu" popover id="dx-menu-rtl">
<div class="menu-label">هذا التصدير</div>
<button class="menu-item">تنزيل<span class="push">⌘D</span></button>
<button class="menu-item">تكرار</button>
<div class="menu-sep"></div>
<button class="menu-item menu-item-danger">حذف</button>
</div>
</div>
<div dir="rtl">
<button class="btn" popovertarget="dx-menu-rtl">إجراءات</button>
<div class="menu" popover id="dx-menu-rtl">
<div class="menu-label">هذا التصدير</div>
<button class="menu-item">تنزيل<span class="push">⌘D</span></button>
<button class="menu-item">تكرار</button>
<div class="menu-sep"></div>
<button class="menu-item menu-item-danger">حذف</button>
</div>
</div>
Reduced motion
The panel fades and rises over
190ms, and items transition their
background over 110ms. The global reset
collapses both to .01ms under
prefers-reduced-motion: reduce, so the menu appears instantly and still
closes correctly — display and overlay remain in the
transition list.
Anchor positioning itself is not animation. A menu flipping above its trigger because there is no room below is a layout decision, and it happens the same way whatever the motion preference.
Printing
src/99-print.css drops menus from print along with the rest of the page
chrome. A menu is a control for operating the page, so on paper it is noise — and a
popover that happened to be open would otherwise print floating over the content.
Overriding it
/* One menu, no layer needed */
<div class="menu" popover style="--anchor:--my-menu;min-inline-size:18rem">
@layer app.components {
/* Open above by preference rather than below */
.menu-up { position-area: block-start span-inline-end; }
/* Full touch targets, at the cost of density */
.menu-item { min-block-size: var(--tap); }
}
Setting --anchor yourself is the supported way to control the pairing;
Deck's generator skips any panel that already has one.
When not to use it
-
Not for site navigation. Use
.navbarwith.nav-link, or.megafor a large panel. A menu is for actions on a thing, not for destinations. -
Not for choosing a value in a form. Use
.select, or.comboif the list is long enough to need filtering. A menu does not submit anything and has no value. -
Not for a confirmation. Use
.modal. A menu light-dismisses on any outside click, so a destructive action one click away inside one is easy to trigger by accident. -
Not for more than about ten items. Past that a reader is scanning
rather than choosing, and there are no arrow keys to move with. Use a
.sheeton a phone, or a searchable.combo. -
Not as a tooltip. Use
.tipfor a label and.popfor a card with content. A menu opens on click and stays open, which is the wrong behaviour for an explanation.