Skip to content

ExpressiveCSS

Browse documentation

Menu

Material Design 3 menus, from the HTML.

View as Markdown

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.

    No matching pages

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

    Default source: Mist Iris.

    ExpressiveCSS presets: Lagoon, Harbor, Breeze, Cobalt, Iris.

    Bold

    Soft

    Mist

    Glow