Menu

Menu

Material Design 3 menus, from the HTML.

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.

  • Refresh
  • Settings
  • Help
  • More
  • <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.

    <!-- 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.

  • One
  • Two
  • Three
  • Four
  • One
  • Two
  • Three
  • Five
  • 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.

  • One
  • Two
  • Three
  • 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.

  • Item 1 4
  • Item 2 Supporting text ⌘C
  • Item 3
  • Item 4
  • Nested one
  • Nested two
  • Item 5
  • Redo
  • <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.

  • Item 1
  • Item 2
  • Item 3
  • <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.

  • One
  • More
  • Nested one
  • Nested two
  • Three
  • <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.
  • Source color

    The seed every generated ramp derives from. Pick one and browse the docs — the whole theme follows. Error does not: it is a fixed hue.