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.

Two attributes. Click outside, or press Escape.
<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.

The panel is exactly as wide as the button
Assign to
<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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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-expanded on the trigger are all the browser's. It also puts the panel in the top layer, so it escapes any overflow: hidden ancestor — 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 roving tabindex, arrow-key navigation, Home and End, and typeahead. A role="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 autofocus to 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-danger is 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 in aria-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.

Aligned to the other edge, shortcut hint on the other side
<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 .navbar with .nav-link, or .mega for 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 .combo if 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 .sheet on a phone, or a searchable .combo.
  • Not as a tooltip. Use .tip for a label and .pop for a card with content. A menu opens on click and stays open, which is the wrong behaviour for an explanation.