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, since a lone
icon is indistinguishable from a leading one in CSS. A
<kbd> or a .badge is always
trailing content. An
<li class="divider" role="separator"> is a divider —
<menu> is a list and permits only <li>,
so a bare <hr> there is invalid (it still renders);
a .gap splits groups; 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, large (16dp)
corners, level 2 elevation, 2dp group padding, and 112–280dp wide.
Items are at least 44dp tall, with 16dp edge spacing, extra-small
(4dp) corners, label-large /
on-surface; 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,
focus and press 10%, none of which 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><a href="#!">Refresh<span class="material-symbols suffix" aria-hidden="true">refresh</span></a></li>
<li><a href="#!">Settings<span class="material-symbols suffix" aria-hidden="true">settings</span></a></li>
<li><a href="#!">Help<span class="material-symbols suffix" aria-hidden="true">help</span></a></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. Nested menus open beside their parent item and switch sides near the viewport edge. Use dividers in scrollable menus; don’t use gaps there. Keep each row to one action, and keep icons decorative when the visible label already names the action.
When opened, focus moves to the first item. Space, Enter, Arrow Down, and Arrow Up open the menu; arrows move between items; letters use typeahead; Escape closes it and returns focus to the trigger. Disabled items remain focusable so their presence and label are discoverable, but they cannot be selected.
Standard and grouped
Standard is the default: every item shares 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.
The gap is skipped by pointer and keyboard navigation.
1. Standard
2. Grouped
<!-- Standard is the default: it has no modifier class. -->
<menu id="standard-menu">
<li><a href="#!">Refresh</a></li>
<li><a href="#!">Settings</a></li>
</menu>
<!-- A gap starts a new grouped surface. -->
<menu id="grouped-menu" class="grouped">
<li><a href="#!">Item 1</a></li>
<li class="selected"><a href="#!">Item 2</a></li>
<li class="gap" aria-hidden="true"></li>
<li><a href="#!">Item 3</a></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
});
});
Per-instance options can also be passed 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. This option is named autoFocus, not autoTrigger. |
constrainWidth |
Boolean | true |
If true, the menu is as wide as the trigger. M3 menus are independently sized — pass false for that. |
container |
Element | null |
Element that will contain the menu. When omitted, the menu is moved next to the trigger. |
coverTrigger |
Boolean | false |
If false, the menu opens below the trigger (the M3 placement). Pass true to cover the trigger. |
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. |
hover |
Boolean | false |
If true, the menu opens on hover instead of 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" and synchronize
aria-checked. Don’t put aria-selected on a
menu item. Trailing
<kbd> is a keyboard shortcut. A
.gap item splits groups — more expressive than a
divider, and limited to one or two per menu. A
.label is a 32dp group heading. Don’t put gaps in
a scrollable menu.
<menu id="menu-rich" class="grouped">
<li><a href="#!"><span class="material-symbols" aria-hidden="true">visibility</span> Item 1 <span class="badge">4</span></a></li>
<li>
<a href="#!">
<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>
</a>
</li>
<li class="selected">
<a href="#!"><span class="material-symbols" aria-hidden="true">check</span> Item 3</a>
</li>
<li class="gap" aria-hidden="true"></li>
<li>
<a href="#!"><span class="material-symbols" aria-hidden="true">person</span> Item 4</a>
<menu>…</menu>
</li>
<li class="disabled" aria-disabled="true">
<a href="#!" tabindex="-1"><span class="material-symbols" aria-hidden="true">redo</span> Redo</a>
</li>
</menu>
Color
Two mappings. Standard (the default) is
surface-based and lower emphasis.
Vibrant is tertiary-based and higher emphasis —
add .vibrant and use it sparingly.
<menu id="menu-vibrant" class="vibrant">
<li><a href="#!"><span class="material-symbols" aria-hidden="true">visibility</span> Item 1</a></li>
<li class="selected"><a href="#!"><span class="material-symbols" aria-hidden="true">check</span> Item 2</a></li>
</menu>
Nested menus
Optional and desktop-first. Put a <menu> inside an
<li> — no extra trigger, no second instance.
On a fine pointer the flyout opens on hover or focus 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.
<button class="menu-trigger" data-target="menu1">Nested</button>
<menu id="menu1">
<li><a href="#!">One</a></li>
<li>
<a href="#!">More</a>
<menu>
<li><a href="#!">Nested one</a></li>
<li><a href="#!">Nested two</a></li>
</menu>
</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.
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. |