A <menu> is the surface. Each <li>
is an item. An icon leads its label by default. Add
.suffix to send it to the trailing edge, because CSS
cannot tell a lone icon from a leading one. A
<kbd> or a .badge is always
trailing content. Use an
<li class="divider" role="separator"> for a divider.
<menu> is a list and permits only <li>,
so a bare <hr> there is invalid and gets no styles.
A .gap splits groups, and a .label is a
heading. A nested <menu> is a flyout. The
trigger's data-target must match the menu's
id. .menu-trigger is the JavaScript contract.
This is the M3 Expressive vertical menu. Tokens follow the
M3 menu spec.
The container is surface-container-low with large (16dp)
corners, level 2 elevation, 2dp group padding, and a width of 112 to 280dp.
Items are at least 44dp tall, with 16dp edge spacing, extra-small
(4dp) corners, and label-large /
on-surface text. The first and last item round their
outer corners to 12dp. Icons are 20dp
on-surface-variant. Selected items use medium (12dp)
corners and tertiary-container /
on-tertiary-container. Hover is an 8% state layer, and
focus and press are 10%. None of them span the container. Dividers
are inset.
AutoInit() starts every .menu-trigger
except those marked no-autoinit. Menus open on click,
below the trigger. Pass coverTrigger: true to cover
the trigger. Pass constrainWidth: false so the menu
sizes independently of the trigger.
<button class="menu-trigger" data-target="menu1">Drop me</button>
<menu id="menu1">
<li><button type="button">Refresh<span class="material-symbols suffix" aria-hidden="true">refresh</span></button></li>
<li><button type="button">Settings<span class="material-symbols suffix" aria-hidden="true">settings</span></button></li>
<li><button type="button">Help<span class="material-symbols suffix" aria-hidden="true">help</span></button></li>
</menu>
For a menu inside a top app bar, see Navbar menu.
Usage and adaptive behavior
Use a menu for a temporary set of actions. Keep actions that must remain visible in a toolbar. On Compact windows, consider replacing long or complex menus with a bottom sheet. Menus and nested flyouts work best in context on Medium and wider layouts.
The component repositions a menu when its preferred edge would be
clipped. In browsers with CSS anchor positioning, CSS anchors a menu opened from its trigger to that trigger. The menu stays under the trigger while the page or a container scrolls. It opens above when the whole menu does not fit below and there is more room above. It flips to the other side at the viewport edge, and it scrolls when it is taller than the room on the side it opens on. Set placement: 'top' to open it above the trigger when it fits there, as for a trigger in a bottom bar. Up and Down move between rows, and Home and End jump to the first and last. Context menus and coverTrigger: true keep the measured placement, as do menus under a transformed ancestor and browsers without anchor positioning. An open menu is in the browser's top layer, so a container that clips or scrolls does not cut it off and a modal dialog does not cover it. Nested menus open beside their parent item and switch sides
near the viewport edge.
Use dividers in scrollable menus, not gaps. Keep each row to one action, and keep icons decorative when the visible label already names the action.
When the menu opens, focus moves to the first item. Space, Enter, Arrow Down, and Arrow Up open the menu. The arrows move between items, and letters use typeahead. Escape closes the menu and returns focus to the trigger. Disabled items stay focusable so users can discover them and their labels, but users cannot select them.
A closing menu drops out of keyboard focus and does not undo focus that a close callback moved. Submenu entry and return arrows mirror in RTL. Typeahead stays in the active list and skips headings and disabled items. Escape closes the innermost submenu first.
Inside open or closed shadow roots, closeOnClick controls
item clicks just as it does in the document. Submenu triggers keep the
parent menu open. Outside clicks dismiss it.
Standard and grouped
Standard is the default, with every item on one
continuous menu surface. Grouped separates related
runs into coplanar surfaces. Add .grouped to the menu
and place an inert <li class="gap"> between groups.
Pointer and keyboard navigation skip the gap.
1. Standard
2. Grouped
<!-- Standard is the default: it has no modifier class. -->
<menu id="standard-menu">
<li><button type="button">Refresh</button></li>
<li><button type="button">Settings</button></li>
</menu>
<!-- A gap starts a new grouped surface. -->
<menu id="grouped-menu" class="grouped">
<li><button type="button">Item 1</button></li>
<li class="selected"><button type="button">Item 2</button></li>
<li class="gap" aria-hidden="true"></li>
<li><button type="button">Item 3</button></li>
</menu>
Initialization
The IIFE bundle exposes Expressive.Menu.
Call init yourself when you need options other than
the defaults, or let Expressive.AutoInit() start
every .menu-trigger.
document.addEventListener('DOMContentLoaded', function() {
Expressive.Menu.init(document.querySelectorAll('.menu-trigger'), {
coverTrigger: false
});
});
You can also pass per-instance options through AutoInit:
Expressive.AutoInit(document.body, {
Menu: { constrainWidth: false, coverTrigger: false }
});
Options
| Name | Type | Default | Description |
|---|---|---|---|
alignment |
String | 'left' |
Edge the menu is aligned to. 'left' or 'right'. |
autoFocus |
Boolean | true |
If true, automatically focus the menu for keyboard navigation. A menu opened by hover leaves focus where it was until Space, Enter or an arrow key on the trigger. This option is named autoFocus, not autoTrigger. |
constrainWidth |
Boolean | true |
If true, the menu is as wide as the trigger. M3 menus size independently of the trigger, so pass false for M3 sizing. |
container |
Element | null |
Element that contains the menu. When omitted, the plugin moves the menu next to the trigger. |
coverTrigger |
Boolean | false |
If false, the menu opens below the trigger (the M3 placement). Pass true to cover the trigger. |
placement |
String | 'bottom' |
The side of the trigger the menu opens on when it fits there: 'bottom' or 'top'. It opens on the other side when that has more room. |
closeOnClick |
Boolean | true |
If true, close the menu when a leaf item is clicked. A click on a row that owns a nested <menu> toggles that flyout and does not close the root. A checkbox item stays open on a click or Space and closes on Enter. A radio item stays open on Space. |
hover |
Boolean | false |
If true, the menu opens on hover instead of click. |
contextMenu |
Boolean | false, or true with data-context-menu |
If true, the trigger is a region and the menu opens at the pointer on right-click, long-press, the menu key or Shift+F10, instead of on click. |
inDuration |
Number | 150 |
Enter transition duration, in milliseconds. |
outDuration |
Number | 250 |
Exit transition duration, in milliseconds. |
onOpenStart |
Function | null |
Called when the menu starts opening. |
onOpenEnd |
Function | null |
Called when the menu finishes opening. A superseded transition or destruction cancels this callback. |
onCloseStart |
Function | null |
Called when the menu starts closing. |
onCloseEnd |
Function | null |
Called when the menu finishes closing. Reopening or destruction cancels this callback. |
onItemClick |
Function | null |
Called when an item is clicked. Receives the li. |
Examples
These two menus set constrainWidth: false so the list
can be wider than the button, and use alignment to
pick an edge.
Expressive.Menu.init(document.querySelector('#left'), {
alignment: 'left',
constrainWidth: false,
coverTrigger: false
});
Hover is off by default. Pass hover: true to open on
mouse enter instead of click.
Expressive.Menu.init(document.querySelector('#hover'), {
hover: true,
constrainWidth: false,
coverTrigger: false
});
Selection, shortcuts, and groups
Mark an ordinary current item with .selected. For a
single-select or multi-select menu, give items
role="menuitemradio" or
role="menuitemcheckbox" with an
aria-checked value. Don't put aria-selected
on a menu item.
A checkbox item toggles its own aria-checked. A click or
Space flips it and keeps the menu open, and Enter flips it and closes
the menu. A radio item checks itself and unchecks the other radio
items in its group. A click or Enter checks it and closes the menu,
and Space checks it and keeps the menu open. Each change fires
expressive:change with
{ item, checked }. Choosing a radio item that is
already checked fires none.
A trailing <kbd> is a keyboard shortcut. A
.gap item splits groups. It is more expressive than a
divider, so use one or two per menu at most. A
.label is a 32dp group heading. Don't put gaps in
a scrollable menu.
<menu id="menu-rich" class="grouped">
<li><button type="button"><span class="material-symbols" aria-hidden="true">visibility</span> Item 1 <span class="badge">4</span></button></li>
<li>
<button type="button">
<span class="material-symbols" aria-hidden="true">content_copy</span>
<span class="menu-item-text">
<span>Item 2</span>
<span class="supporting-text">Supporting text</span>
</span>
<kbd>⌘C</kbd>
</button>
</li>
<li class="selected">
<button type="button"><span class="material-symbols" aria-hidden="true">check</span> Item 3</button>
</li>
<li class="gap" aria-hidden="true"></li>
<li>
<button type="button"><span class="material-symbols" aria-hidden="true">person</span> Item 4</button>
<menu>…</menu>
</li>
<li class="disabled" aria-disabled="true">
<button type="button" tabindex="-1"><span class="material-symbols" aria-hidden="true">redo</span> Redo</button>
</li>
</menu>
Put each set of radio items in a <ul role="group">
with an aria-label, inside an
<li role="none">, so a screen reader names the
choice. Rows in a group look and move like any other row, and arrow
keys pass from one group to the next. Radio items outside a group
form one group per menu.
<button class="menu-trigger" data-target="sort-menu">Sort</button>
<menu id="sort-menu">
<li role="none">
<ul role="group" aria-label="Sort by">
<li role="menuitemradio" aria-checked="true"><button type="button">Name</button></li>
<li role="menuitemradio" aria-checked="false"><button type="button">Date modified</button></li>
<li role="menuitemradio" aria-checked="false"><button type="button">Size</button></li>
</ul>
</li>
<li class="divider" role="separator"></li>
<li role="none">
<ul role="group" aria-label="Order">
<li role="menuitemradio" aria-checked="true"><button type="button">Ascending</button></li>
<li role="menuitemradio" aria-checked="false"><button type="button">Descending</button></li>
</ul>
</li>
</menu>
Color
Menus have two color mappings. Standard (the default) is
surface-based and lower emphasis.
Vibrant is tertiary-based and higher emphasis.
Add .vibrant to use it, and use it sparingly.
<menu id="menu-vibrant" class="vibrant">
<li><button type="button"><span class="material-symbols" aria-hidden="true">visibility</span> Item 1</button></li>
<li class="selected"><button type="button"><span class="material-symbols" aria-hidden="true">check</span> Item 2</button></li>
</menu>
Nested menus
To nest a menu, put a <menu> inside an
<li>. It needs no extra trigger or second instance.
Nested menus are optional and designed desktop-first.
On a fine pointer the flyout opens on hover with a short
scale and fade. The open flyout rounds up and the parent rounds
down. On touch, a tap toggles .open on that row.
A click on the parent row does not close the root menu.
Keyboard users open it with Enter, Space, or the submenu entry arrow.
<button class="menu-trigger" data-target="menu1">Nested</button>
<menu id="menu1">
<li><button type="button">One</button></li>
<li>
<button type="button">More</button>
<menu>
<li><button type="button">Nested one</button></li>
<li><button type="button">Nested two</button></li>
</menu>
</li>
</menu>
Context menu
Add data-context-menu to a .menu-trigger
that is a region of content, such as a file list or a card, and
its menu replaces the browser's context menu there. Right-click
opens it at the pointer, and it flips to stay on screen. The menu
key and Shift+F10 open it at the region's start corner, so give the
region tabindex="0" when it holds nothing focusable.
Enter and Space stay with the region's own content. Closing the menu
returns focus to whatever had it before the menu opened. In nested
regions only the innermost menu opens.
The region gets no aria-expanded or
aria-haspopup, because those attributes belong to buttons.
Every action in a context menu also needs another way to reach it, such as a toolbar or an
item's own menu button. Touch browsers that do not fire
contextmenu on long-press, such as iOS Safari, never
show it.
<div class="menu-trigger" data-context-menu data-target="file-actions" tabindex="0">
…
</div>
<menu id="file-actions">
<li><button type="button">Rename</button></li>
<li><button type="button">Duplicate</button></li>
<li class="divider" role="separator"></li>
<li><button type="button">Delete</button></li>
</menu>
Methods
All methods are called on the plugin instance. You can get the instance like this:
const instance = Expressive.Menu.getInstance(elem);
.open()
Open the menu.
instance.open();
.close()
Close the menu. With autoFocus, focus returns to the trigger if it is still in the menu. Focus that moved to an outside control stays there.
instance.close();
.recalculateDimensions()
While the menu is open, recalculate its dimensions if its contents have changed.
instance.recalculateDimensions();
.destroy()
Destroy the plugin instance and tear down its event handlers.
instance.destroy();
Properties
| Name | Type | Description |
|---|---|---|
el |
Element | The trigger the plugin was initialized with. |
options |
Object | The options the instance was initialized with. |
id |
String | ID of the menu element. |
menuEl |
Element | The menu element. |
isOpen |
Boolean | Whether the menu is open. |
isScrollable |
Boolean | Whether the menu content is scrollable. |
focusedIndex |
Number | Index of the focused item. |