Skip to content

Menu

A temporary set of actions or options, anchored to a trigger. It handles placement, flipping, roving focus, typeahead and dismissal. It is also the dropdown surface inside md-select, md-multi-select, md-autocomplete and md-date-picker.

The menus below start closed — click a trigger to open one.

Live preview — click the trigger Open in Storybook
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-icon-button id="more" icon="more_vert" aria-label="More actions"></md-icon-button>

<md-menu id="overflow" anchor="more" placement="bottom-start">
  <md-menu-item headline="Rename"></md-menu-item>
  <md-menu-item headline="Duplicate"></md-menu-item>
  <md-menu-item headline="Delete"></md-menu-item>
</md-menu>

<script type="module">
  const trigger = document.getElementById('more');
  const menu = document.getElementById('overflow');

  // The menu never wires its own trigger — opening it, and the trigger's ARIA,
  // are both yours.
  trigger.setAttribute('aria-haspopup', 'menu');
  trigger.addEventListener('click', () => menu.show());
  menu.addEventListener('mdOpen',  () => trigger.setAttribute('aria-expanded', 'true'));
  menu.addEventListener('mdClose', () => trigger.setAttribute('aria-expanded', 'false'));
</script>

Already installed? See the Installation guide for one-time package setup (core + tokens, fonts). Each tab below shows two patterns for using md-menu in your project: Option A registers every AWC UI component at once (simplest), Option B imports only this component for tree-shake-friendly bundles.

<!-- ─── Option A: global registration (all components) ─── -->
<script type="module">
  import '@awc-ui/core/define';
</script>


<!-- ─── Option B: single import (tree-shake only md-menu) ─── -->
<script type="module">
  import '@awc-ui/core/components/md-menu';
</script>


<md-menu></md-menu>
  • A temporary set of actions from a trigger: overflow menus, context menus, row actions.
  • More options than fit comfortably on screen — M3 notes a menu takes less space than a set of radio buttons or chips.
  • Nested action groups via md-sub-menu-item.
SituationUse instead
Actions that should always be visiblemd-toolbar
Choosing a value in a formmd-select / md-multi-select
2–5 exclusive optionsmd-segmented-button
A list of recordsmd-list
A blocking decisionmd-dialog
Supplementary panel contentmd-side-sheet / md-bottom-sheet
Top-level navigationmd-navigation-bar / md-navigation-rail
One default action plus variantsmd-split-button
NeedSetting
Commands (rename, delete)Default menu semantics
Values the user picks fromlistbox + presentation="option" rows
Multi-select rowskeep-open on the items (add persistent only if the menu should also survive an outside click)
Sections with headingslayout="grouped" + md-menu-item-group
Surface at least as wide as the triggermatch-anchor-width
A sheet on compact viewportsresponsive
No open animationquick

The menu does not wire the trigger for you. Call show() yourself, and set aria-haspopup / aria-expanded on the trigger — see Accessibility.

Any element can be the trigger — a button, an icon button, a chip, a row:

Three trigger shapes — the wiring is identical Open in Storybook
Button Filter
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="mt-btn" variant="filled" trailing-icon="arrow_drop_down">Button</md-button>
<md-menu id="mt-m1" anchor="mt-btn"><md-menu-item headline="Rename"></md-menu-item><md-menu-item headline="Duplicate"></md-menu-item></md-menu>

<md-icon-button id="mt-icon" icon="more_vert" aria-label="More actions"></md-icon-button>
<md-menu id="mt-m2" anchor="mt-icon"><md-menu-item headline="Rename"></md-menu-item><md-menu-item headline="Delete"></md-menu-item></md-menu>

<md-chip id="mt-chip" appearance="assist">Filter</md-chip>
<md-menu id="mt-m3" anchor="mt-chip"><md-menu-item headline="Newest" type="radio" selected></md-menu-item><md-menu-item headline="Oldest" type="radio"></md-menu-item></md-menu>

<script type="module">
  document.getElementById('mt-btn').addEventListener('click', () => {
    document.getElementById('mt-m1').show();
  });
  document.getElementById('mt-icon').addEventListener('click', () => {
    document.getElementById('mt-m2').show();
  });
  document.getElementById('mt-chip').addEventListener('click', () => {
    document.getElementById('mt-m3').show();
  });
</script>

placement is logical, so bottom-start follows the reading direction. The menu flips to the opposite side when it would overflow the viewport, and slides along the anchor when it would run off the inline edge — you do not opt in.

Placement — and it flips itself near a viewport edge Open in Storybook
bottom-start bottom-end top-start
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="ap-1" variant="outlined">bottom-start</md-button>
<md-menu id="ap-m1" anchor="ap-1" placement="bottom-start"><md-menu-item headline="One"></md-menu-item><md-menu-item headline="Two"></md-menu-item></md-menu>

<md-button id="ap-2" variant="outlined">bottom-end</md-button>
<md-menu id="ap-m2" anchor="ap-2" placement="bottom-end"><md-menu-item headline="One"></md-menu-item><md-menu-item headline="Two"></md-menu-item></md-menu>

<md-button id="ap-3" variant="outlined">top-start</md-button>
<md-menu id="ap-m3" anchor="ap-3" placement="top-start"><md-menu-item headline="One"></md-menu-item><md-menu-item headline="Two"></md-menu-item></md-menu>

<script type="module">
  document.getElementById('ap-1').addEventListener('click', () => {
    document.getElementById('ap-m1').show();
  });
  document.getElementById('ap-2').addEventListener('click', () => {
    document.getElementById('ap-m2').show();
  });
  document.getElementById('ap-3').addEventListener('click', () => {
    document.getElementById('ap-m3').show();
  });
</script>

baseline is the default. vibrant is high emphasis — M3 says use it sparingly.

Three surface tones Open in Storybook
Baseline Standard Vibrant
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="menu-demo-t-baseline" variant="outlined">Baseline</md-button>
<md-menu id="menu-demo-m-baseline" anchor="menu-demo-t-baseline" variant="baseline">
  <md-menu-item headline="Rename"></md-menu-item>
  <md-menu-item headline="Duplicate"></md-menu-item>
</md-menu>

<md-button id="menu-demo-t-standard" variant="outlined">Standard</md-button>
<md-menu id="menu-demo-m-standard" anchor="menu-demo-t-standard" variant="standard">
  <md-menu-item headline="Rename"></md-menu-item>
  <md-menu-item headline="Duplicate"></md-menu-item>
</md-menu>

<md-button id="menu-demo-t-vibrant" variant="outlined">Vibrant</md-button>
<md-menu id="menu-demo-m-vibrant" anchor="menu-demo-t-vibrant" variant="vibrant">
  <md-menu-item headline="Rename"></md-menu-item>
  <md-menu-item headline="Duplicate"></md-menu-item>
</md-menu>

<script type="module">
  document.getElementById('menu-demo-t-baseline').addEventListener('click', () => {
    document.getElementById('menu-demo-m-baseline').show();
  });
  document.getElementById('menu-demo-t-standard').addEventListener('click', () => {
    document.getElementById('menu-demo-m-standard').show();
  });
  document.getElementById('menu-demo-t-vibrant').addEventListener('click', () => {
    document.getElementById('menu-demo-m-vibrant').show();
  });
</script>

type="checkbox" and type="radio" turn rows into selection controls. Pair them with keep-open so picking one doesn’t dismiss the menu, and use check-position="start" when the checkmark should lead instead of trail. Note that keep-open is all you need here: adding persistent to the menu would also switch off outside-click dismissal, which a preference or sort menu should keep:

Checkbox and radio rows, with the check leading or trailing Open in Storybook
Checkbox — check end (default) Preferences notifications dark_mode view_compact
Checkbox — check start Preferences notifications dark_mode view_compact
Radio — check end (default) Sort arrow_upward arrow_downward sort_by_alpha
Radio — check start Sort arrow_upward arrow_downward sort_by_alpha
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: flex; gap: 32px; flex-wrap: wrap;">
  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">Checkbox — check end (default)</span>
    <md-button id="sel-cb-end-t" variant="outlined">Preferences</md-button>
    <md-menu id="sel-cb-end-m" anchor="sel-cb-end-t" variant="standard">
      <md-menu-item headline="Notifications" type="checkbox" keep-open selected><span slot="leading-icon" class="material-symbols-outlined">notifications</span></md-menu-item>
      <md-menu-item headline="Dark mode" type="checkbox" keep-open><span slot="leading-icon" class="material-symbols-outlined">dark_mode</span></md-menu-item>
      <md-menu-item headline="Compact view" type="checkbox" keep-open><span slot="leading-icon" class="material-symbols-outlined">view_compact</span></md-menu-item>
    </md-menu>
  </div>
  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">Checkbox — check start</span>
    <md-button id="sel-cb-start-t" variant="outlined">Preferences</md-button>
    <md-menu id="sel-cb-start-m" anchor="sel-cb-start-t" variant="standard" check-position="start">
      <md-menu-item headline="Notifications" type="checkbox" keep-open selected><span slot="leading-icon" class="material-symbols-outlined">notifications</span></md-menu-item>
      <md-menu-item headline="Dark mode" type="checkbox" keep-open><span slot="leading-icon" class="material-symbols-outlined">dark_mode</span></md-menu-item>
      <md-menu-item headline="Compact view" type="checkbox" keep-open><span slot="leading-icon" class="material-symbols-outlined">view_compact</span></md-menu-item>
    </md-menu>
  </div>
  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">Radio — check end (default)</span>
    <md-button id="sel-rd-end-t" variant="outlined">Sort</md-button>
    <md-menu id="sel-rd-end-m" anchor="sel-rd-end-t" variant="standard">
      <md-menu-item headline="Newest first" type="radio" keep-open selected><span slot="leading-icon" class="material-symbols-outlined">arrow_upward</span></md-menu-item>
      <md-menu-item headline="Oldest first" type="radio" keep-open><span slot="leading-icon" class="material-symbols-outlined">arrow_downward</span></md-menu-item>
      <md-menu-item headline="Name" type="radio" keep-open><span slot="leading-icon" class="material-symbols-outlined">sort_by_alpha</span></md-menu-item>
    </md-menu>
  </div>
  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">Radio — check start</span>
    <md-button id="sel-rd-start-t" variant="outlined">Sort</md-button>
    <md-menu id="sel-rd-start-m" anchor="sel-rd-start-t" variant="standard" check-position="start">
      <md-menu-item headline="Newest first" type="radio" keep-open selected><span slot="leading-icon" class="material-symbols-outlined">arrow_upward</span></md-menu-item>
      <md-menu-item headline="Oldest first" type="radio" keep-open><span slot="leading-icon" class="material-symbols-outlined">arrow_downward</span></md-menu-item>
      <md-menu-item headline="Name" type="radio" keep-open><span slot="leading-icon" class="material-symbols-outlined">sort_by_alpha</span></md-menu-item>
    </md-menu>
  </div>
</div>

<script type="module">
  document.getElementById('sel-cb-end-t').addEventListener('click', () => {
    document.getElementById('sel-cb-end-m').show();
  });
  document.getElementById('sel-cb-start-t').addEventListener('click', () => {
    document.getElementById('sel-cb-start-m').show();
  });
  document.getElementById('sel-rd-end-t').addEventListener('click', () => {
    document.getElementById('sel-rd-end-m').show();
  });
  document.getElementById('sel-rd-start-t').addEventListener('click', () => {
    document.getElementById('sel-rd-start-m').show();
  });
</script>

One menu can mix a command, a checkbox and a radio group — the row type is per row, not per menu:

Mixed row types in one menu Open in Storybook
View refresh view_sidebar density_large density_small
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="sel-mix-t" variant="outlined">View</md-button>
<md-menu id="sel-mix-m" anchor="sel-mix-t" variant="standard">
  <md-menu-item headline="Reload"><span slot="leading-icon" class="material-symbols-outlined">refresh</span></md-menu-item>
  <md-menu-item headline="Show sidebar" type="checkbox" keep-open selected divider><span slot="leading-icon" class="material-symbols-outlined">view_sidebar</span></md-menu-item>
  <md-menu-item headline="Comfortable" type="radio" keep-open selected><span slot="leading-icon" class="material-symbols-outlined">density_large</span></md-menu-item>
  <md-menu-item headline="Compact" type="radio" keep-open><span slot="leading-icon" class="material-symbols-outlined">density_small</span></md-menu-item>
</md-menu>

<script type="module">
  document.getElementById('sel-mix-t').addEventListener('click', () => {
    document.getElementById('sel-mix-m').show();
  });
</script>

Any control can anchor a menu — the wiring is identical, only the trigger and the menu’s variant change:

Three trigger shapes, three surface tones Open in Storybook
Filled button — baseline menu Actions edit content_copy delete drive_file_move
Outlined button — standard menu File note_add folder_open save download
Icon button — vibrant menu edit share archive
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: flex; gap: 40px; flex-wrap: wrap;">
  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">Filled button — baseline menu</span>
    <md-button id="tr-bl-t" variant="filled">Actions</md-button>
    <md-menu id="tr-bl-m" anchor="tr-bl-t" variant="baseline">
      <md-menu-item headline="Edit"><span slot="leading-icon" class="material-symbols-outlined">edit</span></md-menu-item>
      <md-menu-item headline="Duplicate"><span slot="leading-icon" class="material-symbols-outlined">content_copy</span></md-menu-item>
      <md-menu-item headline="Delete" divider><span slot="leading-icon" class="material-symbols-outlined">delete</span></md-menu-item>
      <md-menu-item headline="Move to"><span slot="leading-icon" class="material-symbols-outlined">drive_file_move</span></md-menu-item>
    </md-menu>
  </div>

  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">Outlined button — standard menu</span>
    <md-button id="tr-st-t" variant="outlined">File</md-button>
    <md-menu id="tr-st-m" anchor="tr-st-t" variant="standard">
      <md-menu-item headline="New" trailing-text="⌘N"><span slot="leading-icon" class="material-symbols-outlined">note_add</span></md-menu-item>
      <md-menu-item headline="Open" trailing-text="⌘O"><span slot="leading-icon" class="material-symbols-outlined">folder_open</span></md-menu-item>
      <md-menu-item headline="Save" trailing-text="⌘S" divider><span slot="leading-icon" class="material-symbols-outlined">save</span></md-menu-item>
      <md-menu-item headline="Export"><span slot="leading-icon" class="material-symbols-outlined">download</span></md-menu-item>
    </md-menu>
  </div>

  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">Icon button — vibrant menu</span>
    <md-icon-button id="tr-vb-t" icon="more_vert" aria-label="More actions"></md-icon-button>
    <md-menu id="tr-vb-m" anchor="tr-vb-t" variant="vibrant">
      <md-menu-item headline="Rename"><span slot="leading-icon" class="material-symbols-outlined">edit</span></md-menu-item>
      <md-menu-item headline="Share"><span slot="leading-icon" class="material-symbols-outlined">share</span></md-menu-item>
      <md-menu-item headline="Archive"><span slot="leading-icon" class="material-symbols-outlined">archive</span></md-menu-item>
    </md-menu>
  </div>
</div>

<script type="module">
  document.getElementById('tr-bl-t').addEventListener('click', () => {
    document.getElementById('tr-bl-m').show();
  });
  document.getElementById('tr-st-t').addEventListener('click', () => {
    document.getElementById('tr-st-m').show();
  });
  document.getElementById('tr-vb-t').addEventListener('click', () => {
    document.getElementById('tr-vb-m').show();
  });
</script>

variant="vibrant" tints the surface and the selected row with the primary / secondary container roles instead of the flat neutral surface — for menus that carry navigation rather than one-off commands.

Vibrant — flat, and grouped with gaps Open in Storybook
Vibrant, flat inbox star send drafts Vibrant, grouped dashboard analytics description settings
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: flex; gap: 24px; flex-wrap: wrap;">
  <md-button id="vib-flat-t" variant="outlined">Vibrant, flat</md-button>
  <md-menu id="vib-flat-m" anchor="vib-flat-t" variant="vibrant">
    <md-menu-item headline="Inbox"><span slot="leading-icon" class="material-symbols-outlined">inbox</span></md-menu-item>
    <md-menu-item headline="Starred" selected><span slot="leading-icon" class="material-symbols-outlined">star</span></md-menu-item>
    <md-menu-item headline="Sent"><span slot="leading-icon" class="material-symbols-outlined">send</span></md-menu-item>
    <md-menu-item headline="Drafts"><span slot="leading-icon" class="material-symbols-outlined">drafts</span></md-menu-item>
  </md-menu>

  <md-button id="vib-gap-t" variant="outlined">Vibrant, grouped</md-button>
  <md-menu id="vib-gap-m" anchor="vib-gap-t" variant="vibrant" layout="grouped" use-gap>
    <md-menu-item-group label="Overview">
      <md-menu-item headline="Dashboard"><span slot="leading-icon" class="material-symbols-outlined">dashboard</span></md-menu-item>
      <md-menu-item headline="Analytics"><span slot="leading-icon" class="material-symbols-outlined">analytics</span></md-menu-item>
    </md-menu-item-group>
    <md-menu-item-group label="Tools">
      <md-menu-item headline="Reports"><span slot="leading-icon" class="material-symbols-outlined">description</span></md-menu-item>
      <md-menu-item headline="Settings"><span slot="leading-icon" class="material-symbols-outlined">settings</span></md-menu-item>
    </md-menu-item-group>
  </md-menu>
</div>

<script type="module">
  document.getElementById('vib-flat-t').addEventListener('click', () => {
    document.getElementById('vib-flat-m').show();
  });
  document.getElementById('vib-gap-t').addEventListener('click', () => {
    document.getElementById('vib-gap-m').show();
  });
</script>

layout="grouped" sections the surface; the header slot sits above the rows and scrolls with them.

use-gap on a grouped menu swaps the divider between sections for real whitespace, and each md-menu-item-group keeps its own label. Compare the two variants — standard keeps the flat surface, vibrant tints the group bands:

Grouped with use-gap — standard next to vibrant Open in Storybook
Standard note_add folder_open save undo redo Vibrant inbox star send archive delete
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: flex; gap: 24px; flex-wrap: wrap;">
  <md-button id="gap-std-t" variant="outlined">Standard</md-button>
  <md-menu id="gap-std-m" anchor="gap-std-t" variant="standard" layout="grouped" use-gap>
    <md-menu-item-group label="File">
      <md-menu-item headline="New"><span slot="leading-icon" class="material-symbols-outlined">note_add</span></md-menu-item>
      <md-menu-item headline="Open"><span slot="leading-icon" class="material-symbols-outlined">folder_open</span></md-menu-item>
      <md-menu-item headline="Save" trailing-text="⌘S"><span slot="leading-icon" class="material-symbols-outlined">save</span></md-menu-item>
    </md-menu-item-group>
    <md-menu-item-group label="Edit">
      <md-menu-item headline="Undo" trailing-text="⌘Z"><span slot="leading-icon" class="material-symbols-outlined">undo</span></md-menu-item>
      <md-menu-item headline="Redo" trailing-text="⌘⇧Z"><span slot="leading-icon" class="material-symbols-outlined">redo</span></md-menu-item>
    </md-menu-item-group>
  </md-menu>

  <md-button id="gap-vib-t" variant="outlined">Vibrant</md-button>
  <md-menu id="gap-vib-m" anchor="gap-vib-t" variant="vibrant" layout="grouped" use-gap>
    <md-menu-item-group label="Mail">
      <md-menu-item headline="Inbox"><span slot="leading-icon" class="material-symbols-outlined">inbox</span></md-menu-item>
      <md-menu-item headline="Starred" selected><span slot="leading-icon" class="material-symbols-outlined">star</span></md-menu-item>
      <md-menu-item headline="Sent"><span slot="leading-icon" class="material-symbols-outlined">send</span></md-menu-item>
    </md-menu-item-group>
    <md-menu-item-group label="Manage">
      <md-menu-item headline="Archive"><span slot="leading-icon" class="material-symbols-outlined">archive</span></md-menu-item>
      <md-menu-item headline="Delete"><span slot="leading-icon" class="material-symbols-outlined">delete</span></md-menu-item>
    </md-menu-item-group>
  </md-menu>
</div>

<script type="module">
  document.getElementById('gap-std-t').addEventListener('click', () => {
    document.getElementById('gap-std-m').show();
  });
  document.getElementById('gap-vib-t').addEventListener('click', () => {
    document.getElementById('gap-vib-m').show();
  });
</script>

Groups work without labels too — the cards alone carry the grouping — and use-gap is what swaps the divider for that separation:

Unlabelled groups, and gap against divider Open in Storybook
Standard — no labels Edit undo redo content_cut content_copy
Vibrant — no labels Edit undo redo content_cut content_copy
use-gap — cards With gap undo redo content_cut content_copy
no use-gap — divider With divider undo redo content_cut content_copy
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: flex; gap: 32px; flex-wrap: wrap;">
  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">Standard — no labels</span>
    <md-button id="gap-nl-std-t" variant="outlined">Edit</md-button>
    <md-menu id="gap-nl-std-m" anchor="gap-nl-std-t" variant="standard" layout="grouped" use-gap>
      <md-menu-item-group>
        <md-menu-item headline="Undo" trailing-text="⌘Z"><span slot="leading-icon" class="material-symbols-outlined">undo</span></md-menu-item>
        <md-menu-item headline="Redo" trailing-text="⌘⇧Z"><span slot="leading-icon" class="material-symbols-outlined">redo</span></md-menu-item>
      </md-menu-item-group>
      <md-menu-item-group>
        <md-menu-item headline="Cut" trailing-text="⌘X"><span slot="leading-icon" class="material-symbols-outlined">content_cut</span></md-menu-item>
        <md-menu-item headline="Copy" trailing-text="⌘C"><span slot="leading-icon" class="material-symbols-outlined">content_copy</span></md-menu-item>
      </md-menu-item-group>
    </md-menu>
  </div>
  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">Vibrant — no labels</span>
    <md-button id="gap-nl-vib-t" variant="outlined">Edit</md-button>
    <md-menu id="gap-nl-vib-m" anchor="gap-nl-vib-t" variant="vibrant" layout="grouped" use-gap>
      <md-menu-item-group>
        <md-menu-item headline="Undo" trailing-text="⌘Z"><span slot="leading-icon" class="material-symbols-outlined">undo</span></md-menu-item>
        <md-menu-item headline="Redo" trailing-text="⌘⇧Z"><span slot="leading-icon" class="material-symbols-outlined">redo</span></md-menu-item>
      </md-menu-item-group>
      <md-menu-item-group>
        <md-menu-item headline="Cut" trailing-text="⌘X"><span slot="leading-icon" class="material-symbols-outlined">content_cut</span></md-menu-item>
        <md-menu-item headline="Copy" trailing-text="⌘C"><span slot="leading-icon" class="material-symbols-outlined">content_copy</span></md-menu-item>
      </md-menu-item-group>
    </md-menu>
  </div>
  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">use-gap — cards</span>
    <md-button id="gap-cmp-gap-t" variant="outlined">With gap</md-button>
    <md-menu id="gap-cmp-gap-m" anchor="gap-cmp-gap-t" variant="standard" layout="grouped" use-gap>
      <md-menu-item-group label="History">
        <md-menu-item headline="Undo" trailing-text="⌘Z"><span slot="leading-icon" class="material-symbols-outlined">undo</span></md-menu-item>
        <md-menu-item headline="Redo" trailing-text="⌘⇧Z"><span slot="leading-icon" class="material-symbols-outlined">redo</span></md-menu-item>
      </md-menu-item-group>
      <md-menu-item-group label="Clipboard">
        <md-menu-item headline="Cut" trailing-text="⌘X"><span slot="leading-icon" class="material-symbols-outlined">content_cut</span></md-menu-item>
        <md-menu-item headline="Copy" trailing-text="⌘C"><span slot="leading-icon" class="material-symbols-outlined">content_copy</span></md-menu-item>
      </md-menu-item-group>
    </md-menu>
  </div>
  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">no use-gap — divider</span>
    <md-button id="gap-cmp-div-t" variant="outlined">With divider</md-button>
    <md-menu id="gap-cmp-div-m" anchor="gap-cmp-div-t" variant="standard" layout="grouped">
      <md-menu-item-group label="History">
        <md-menu-item headline="Undo" trailing-text="⌘Z"><span slot="leading-icon" class="material-symbols-outlined">undo</span></md-menu-item>
        <md-menu-item headline="Redo" trailing-text="⌘⇧Z"><span slot="leading-icon" class="material-symbols-outlined">redo</span></md-menu-item>
      </md-menu-item-group>
      <md-menu-item-group label="Clipboard">
        <md-menu-item headline="Cut" trailing-text="⌘X"><span slot="leading-icon" class="material-symbols-outlined">content_cut</span></md-menu-item>
        <md-menu-item headline="Copy" trailing-text="⌘C"><span slot="leading-icon" class="material-symbols-outlined">content_copy</span></md-menu-item>
      </md-menu-item-group>
    </md-menu>
  </div>
</div>

<script type="module">
  document.getElementById('gap-nl-std-t').addEventListener('click', () => {
    document.getElementById('gap-nl-std-m').show();
  });
  document.getElementById('gap-nl-vib-t').addEventListener('click', () => {
    document.getElementById('gap-nl-vib-m').show();
  });
  document.getElementById('gap-cmp-gap-t').addEventListener('click', () => {
    document.getElementById('gap-cmp-gap-m').show();
  });
  document.getElementById('gap-cmp-div-t').addEventListener('click', () => {
    document.getElementById('gap-cmp-div-m').show();
  });
</script>

The menu closes on item activation by default. Use persistent on the menu, or keep-open on individual items, for multi-select-style menus. listbox switches the surface to option semantics — use it when the menu presents values, not commands.

Persistent listbox Open in Storybook
Columns
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="menu-demo-t-cols" variant="outlined">Columns</md-button>
<md-menu id="menu-demo-m-cols" anchor="menu-demo-t-cols" persistent listbox list-label="Columns">
  <md-menu-item headline="Name" type="checkbox" keep-open selected></md-menu-item>
  <md-menu-item headline="Owner" type="checkbox" keep-open></md-menu-item>
  <md-menu-item headline="Modified" type="checkbox" keep-open selected></md-menu-item>
</md-menu>

<script type="module">
  document.getElementById('menu-demo-t-cols').addEventListener('click', () => {
    document.getElementById('menu-demo-m-cols').show();
  });
</script>
Nested groups Open in Storybook
File
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="menu-demo-t-sub" variant="outlined">File</md-button>
<md-menu id="menu-demo-m-sub" anchor="menu-demo-t-sub">
  <md-menu-item headline="New"></md-menu-item>
  <md-sub-menu-item headline="Share">
    <md-menu slot="submenu">
      <md-menu-item headline="Copy link"></md-menu-item>
      <md-menu-item headline="Email"></md-menu-item>
    </md-menu>
  </md-sub-menu-item>
  <md-menu-item headline="Delete"></md-menu-item>
</md-menu>

<script type="module">
  document.getElementById('menu-demo-t-sub').addEventListener('click', () => {
    document.getElementById('menu-demo-m-sub').show();
  });
</script>

match-anchor-width makes the surface at least as wide as the trigger — it still grows wider if the rows need it. max-height caps the surface; empty-text covers the no-rows case.

Anchor width, max height, empty text Open in Storybook
Match anchor width Empty
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="menu-demo-t-wide" variant="outlined">Match anchor width</md-button>
<md-menu id="menu-demo-m-wide" anchor="menu-demo-t-wide" match-anchor-width max-height="200">
  <md-menu-item headline="One"></md-menu-item>
  <md-menu-item headline="Two"></md-menu-item>
  <md-menu-item headline="Three"></md-menu-item>
  <md-menu-item headline="Four"></md-menu-item>
  <md-menu-item headline="Five"></md-menu-item>
</md-menu>

<md-button id="menu-demo-t-empty" variant="outlined">Empty</md-button>
<md-menu id="menu-demo-m-empty" anchor="menu-demo-t-empty" empty-text="No actions"></md-menu>

<script type="module">
  document.getElementById('menu-demo-t-wide').addEventListener('click', () => {
    document.getElementById('menu-demo-m-wide').show();
  });
  document.getElementById('menu-demo-t-empty').addEventListener('click', () => {
    document.getElementById('menu-demo-m-empty').show();
  });
</script>

Set responsive to present a near-full-width sheet on compact viewports, and quick to skip the open animation.

A row carries a headline plus any of: a leading icon, supporting text, a badge, trailing text, and a trailing icon. Everything is optional — a menu of plain headlines is a perfectly good menu.

Icons, supporting text, badges, trailing text — and a menu with none of it Open in Storybook
Full anatomy No icons
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="ra-t" variant="outlined">Full anatomy</md-button>
<md-menu id="ra-m" anchor="ra-t">
  <md-menu-item headline="Rename" icon="edit" trailing-text="⌘R"></md-menu-item>
  <md-menu-item headline="Duplicate" icon="content_copy" supporting-text="Creates a copy in place"></md-menu-item>
  <md-menu-item headline="Notifications" icon="notifications" badge="3"></md-menu-item>
  <md-menu-item headline="Delete" icon="delete" trailing-text="⌫"></md-menu-item>
</md-menu>

<md-button id="ra-t2" variant="outlined">No icons</md-button>
<md-menu id="ra-m2" anchor="ra-t2">
  <md-menu-item headline="Rename"></md-menu-item>
  <md-menu-item headline="Duplicate"></md-menu-item>
  <md-menu-item headline="Delete"></md-menu-item>
</md-menu>

<script type="module">
  document.getElementById('ra-t').addEventListener('click', () => {
    document.getElementById('ra-m').show();
  });
  document.getElementById('ra-t2').addEventListener('click', () => {
    document.getElementById('ra-m2').show();
  });
</script>

disabled takes the row out of the roving focus order. Selection state comes from type="checkbox" / type="radio" plus selected; indeterminate is a presentational mixed state on checkbox rows only.

Enabled, disabled, checked, unchecked, indeterminate Open in Storybook
States
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="rs-t" variant="outlined">States</md-button>
<md-menu id="rs-m" anchor="rs-t">
  <md-menu-item headline="Enabled" icon="check_circle"></md-menu-item>
  <md-menu-item headline="Disabled" icon="block" disabled></md-menu-item>
  <md-menu-item headline="Checked" type="checkbox" selected keep-open></md-menu-item>
  <md-menu-item headline="Unchecked" type="checkbox" keep-open></md-menu-item>
  <md-menu-item headline="Indeterminate" type="checkbox" indeterminate keep-open></md-menu-item>
</md-menu>

<script type="module">
  document.getElementById('rs-t').addEventListener('click', () => {
    document.getElementById('rs-m').show();
  });
</script>
Counts on menu rows Open in Storybook
Badges
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="bd-t" variant="outlined">Badges</md-button>
<md-menu id="bd-m" anchor="bd-t">
  <md-menu-item headline="Inbox" icon="inbox" badge="3"></md-menu-item>
  <md-menu-item headline="Drafts" icon="drafts" badge="12"></md-menu-item>
  <md-menu-item headline="Spam" icon="report" badge="99+"></md-menu-item>
  <md-menu-item headline="Archive" icon="archive"></md-menu-item>
</md-menu>

<script type="module">
  document.getElementById('bd-t').addEventListener('click', () => {
    document.getElementById('bd-m').show();
  });
</script>
Two levels — which is one level more than you should ship Open in Storybook
Nested
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="ns-t" variant="outlined">Nested</md-button>
<md-menu id="ns-m" anchor="ns-t">
  <md-menu-item headline="New" icon="add"></md-menu-item>
  <md-sub-menu-item headline="Share" icon="share">
    <md-menu slot="submenu">
      <md-menu-item headline="Copy link"></md-menu-item>
      <md-sub-menu-item headline="Send to">
        <md-menu slot="submenu">
          <md-menu-item headline="Email"></md-menu-item>
          <md-menu-item headline="Slack"></md-menu-item>
        </md-menu>
      </md-sub-menu-item>
    </md-menu>
  </md-sub-menu-item>
  <md-menu-item headline="Delete" icon="delete"></md-menu-item>
</md-menu>

<script type="module">
  document.getElementById('ns-t').addEventListener('click', () => {
    document.getElementById('ns-m').show();
  });
</script>

Three levels, both variants, as a stress test — every level is its own md-menu in the parent row’s submenu slot, and each one flips side or direction on its own when it would leave the viewport:

Three levels deep — standard and vibrant Open in Storybook
Standard — 3 levels File note_add history folder download image picture_as_pdf table_chart save
Vibrant — 3 levels Share link public tag work more_horiz send email chat wifi code
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: flex; gap: 40px; flex-wrap: wrap;">
  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">Standard — 3 levels</span>
    <md-button id="deep-std-t" variant="filled">File</md-button>
    <md-menu id="deep-std-m" anchor="deep-std-t" variant="standard">
      <md-menu-item headline="New file" trailing-text="⌘N"><span slot="leading-icon" class="material-symbols-outlined">note_add</span></md-menu-item>
      <md-sub-menu-item headline="Open recent" supporting-text="Last 5 files">
        <span slot="leading-icon" class="material-symbols-outlined">history</span>
        <md-menu slot="submenu" variant="standard">
          <md-menu-item headline="document.txt" supporting-text="Modified yesterday"></md-menu-item>
          <md-menu-item headline="styles.css" supporting-text="Modified 3 days ago"></md-menu-item>
          <md-sub-menu-item headline="Projects" supporting-text="Browse all">
            <span slot="leading-icon" class="material-symbols-outlined">folder</span>
            <md-menu slot="submenu" variant="standard">
              <md-menu-item headline="my-app" supporting-text="React project"></md-menu-item>
              <md-menu-item headline="website" supporting-text="Static site"></md-menu-item>
              <md-menu-item headline="design-system" supporting-text="Stencil components"></md-menu-item>
            </md-menu>
          </md-sub-menu-item>
        </md-menu>
      </md-sub-menu-item>
      <md-sub-menu-item headline="Export as" supporting-text="Save to disk" divider>
        <span slot="leading-icon" class="material-symbols-outlined">download</span>
        <md-menu slot="submenu" variant="standard">
          <md-sub-menu-item headline="Image" supporting-text="Raster or vector">
            <span slot="leading-icon" class="material-symbols-outlined">image</span>
            <md-menu slot="submenu" variant="standard">
              <md-menu-item headline="PNG"></md-menu-item>
              <md-menu-item headline="JPEG"></md-menu-item>
              <md-menu-item headline="SVG"></md-menu-item>
            </md-menu>
          </md-sub-menu-item>
          <md-menu-item headline="PDF"><span slot="leading-icon" class="material-symbols-outlined">picture_as_pdf</span></md-menu-item>
          <md-menu-item headline="CSV"><span slot="leading-icon" class="material-symbols-outlined">table_chart</span></md-menu-item>
        </md-menu>
      </md-sub-menu-item>
      <md-menu-item headline="Save" trailing-text="⌘S"><span slot="leading-icon" class="material-symbols-outlined">save</span></md-menu-item>
    </md-menu>
  </div>

  <div style="display: grid; gap: 8px; justify-items: start;">
    <span style="font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);">Vibrant — 3 levels</span>
    <md-button id="deep-vib-t" variant="tonal">Share</md-button>
    <md-menu id="deep-vib-m" anchor="deep-vib-t" variant="vibrant">
      <md-menu-item headline="Copy link" supporting-text="To clipboard"><span slot="leading-icon" class="material-symbols-outlined">link</span></md-menu-item>
      <md-sub-menu-item headline="Social" supporting-text="Post to a network">
        <span slot="leading-icon" class="material-symbols-outlined">public</span>
        <md-menu slot="submenu" variant="vibrant">
          <md-menu-item headline="Twitter" supporting-text="Post a tweet"><span slot="leading-icon" class="material-symbols-outlined">tag</span></md-menu-item>
          <md-menu-item headline="LinkedIn" supporting-text="Share with your network"><span slot="leading-icon" class="material-symbols-outlined">work</span></md-menu-item>
          <md-sub-menu-item headline="More" supporting-text="Other platforms">
            <span slot="leading-icon" class="material-symbols-outlined">more_horiz</span>
            <md-menu slot="submenu" variant="vibrant">
              <md-menu-item headline="Facebook"></md-menu-item>
              <md-menu-item headline="Reddit"></md-menu-item>
              <md-menu-item headline="Mastodon"></md-menu-item>
            </md-menu>
          </md-sub-menu-item>
        </md-menu>
      </md-sub-menu-item>
      <md-sub-menu-item headline="Send to" supporting-text="Direct message" divider>
        <span slot="leading-icon" class="material-symbols-outlined">send</span>
        <md-menu slot="submenu" variant="vibrant">
          <md-menu-item headline="Email" supporting-text="Compose a message"><span slot="leading-icon" class="material-symbols-outlined">email</span></md-menu-item>
          <md-menu-item headline="Messages" supporting-text="Via iMessage"><span slot="leading-icon" class="material-symbols-outlined">chat</span></md-menu-item>
          <md-menu-item headline="AirDrop" supporting-text="Nearby devices"><span slot="leading-icon" class="material-symbols-outlined">wifi</span></md-menu-item>
        </md-menu>
      </md-sub-menu-item>
      <md-menu-item headline="Embed" supporting-text="Get the embed code"><span slot="leading-icon" class="material-symbols-outlined">code</span></md-menu-item>
    </md-menu>
  </div>
</div>

<script type="module">
  document.getElementById('deep-std-t').addEventListener('click', () => {
    document.getElementById('deep-std-m').show();
  });
  document.getElementById('deep-vib-t').addEventListener('click', () => {
    document.getElementById('deep-vib-m').show();
  });
</script>
EventCancelableDetailFires
mdOpennovoidThe menu opened
mdClosenovoidThe menu closed
<md-icon-button id="more" icon="more_vert" aria-label="More actions"></md-icon-button>
<md-menu id="menu" anchor="more" placement="bottom-end">
  <md-menu-item headline="Rename"></md-menu-item>
  <md-menu-item headline="Delete"></md-menu-item>
</md-menu>

<script type="module">
  const menu = document.getElementById('menu');
  document.getElementById('more').addEventListener('mdClick', () => menu.show());

  menu.addEventListener('mdOpen', (e) => {
    if (e.target !== menu) return;   // composed: ignore the re-fire on a host
    trackOpen();
  });
  menu.addEventListener('mdClose', (e) => {
    if (e.target !== menu) return;
    trackClose();
  });
</script>

show(opts?) and close() drive the menu. reposition() exists for when the anchor moves — the menu reacts to scroll and resize, but does not observe arbitrary layout changes:

window.addEventListener('resize', () => menu.open && menu.reposition());

setComboboxElement() and setVirtualProvider() are integration hooks for composite pickers with virtualized option lists. Application code should not call them.

Properties

PropertyAttributeTypeDefaultReflects
openopenbooleanfalseYes
anchoranchorstring''
placementplacement'bottom-start' | 'bottom-end' | 'top-start' | 'top-end''bottom-start'Yes
quickquickbooleanfalse
variantvariant'baseline' | 'standard' | 'vibrant''baseline'Yes
layoutlayout'standard' | 'grouped''standard'Yes
useGapuse-gapbooleanfalseYes
emptyTextempty-textstring''
matchAnchorWidthmatch-anchor-widthbooleanfalseYes
responsiveresponsivebooleanfalseYes
persistentpersistentbooleanfalseYes
autoFocusauto-focusbooleantrueYes
maxHeightmax-heightnumber | string
listLabellist-labelstring
listboxlistboxbooleanfalseYes
densitydensity0 | -1 | -2 | -3 | -40Yes

Methods

MethodParameters
setComboboxElement()el: HTMLElement | null
setVirtualProvider()provider: VirtualMenuProvider | null
getScrollViewport()none
show()opts?: { autoFocus?: boolean }
reposition()none
close()none

Slots

SlotDescription
(default)
header

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-menu-min-widthMin container width (default 112px)
--md-menu-max-widthMax container width (default 280px)
--md-menu-inline-surface-colorBackground for md-menu--inline-fill only (default transparent)
--md-menu-inline-fill-radius-endBottom corner radius for inline-fill embed (default 0)
--md-menu-viewport-margin
--md-menu-group-gap
--md-menu-section-gap

CSS Shadow Parts

Style internal elements through shadow DOM with ::part():

PartDescription
empty-text
surface
menu-viewport

A menu is composed from three row types. They are documented here rather than on their own pages: none of them mean anything outside a menu, and the parent owns focus, keyboard navigation and dismissal for all of them.

One row in the menu — a command, or a checkbox/radio option, with optional icons, supporting text, a badge and trailing text.

  • Activating a row closes the menu unless keep-open is set on the item or persistent on the menu. keep-open is the per-row opt-out; persistent is the whole-menu one and additionally disables outside-click dismissal, so reach for it only when you manage closing yourself.
  • type="radio" does not group itself. Put radio rows in an md-menu-item-group and enforce exclusivity in your handler — nothing does it for you.
  • mdClick carries no payload. Identify the row by the element you bound the listener to, or by its headline.
  • indeterminate is presentational only — a mixed state on a checkbox row. It is ignored for button / radio types and when selected is true.
  • presentation and role-override change the announced role. Leave them alone unless you are building a picker; md-select uses presentation="option" so its popup is a real listbox rather than a menu.
  • density here overrides the density inherited from the menu.

Properties

PropertyAttributeTypeDefaultReflects
headlineheadlinestring''
supportingTextsupporting-textstring''
trailingTexttrailing-textstring''
disableddisabledbooleanfalseYes
selectedselectedbooleanfalseYes
indeterminateindeterminatebooleanfalseYes
keepOpenkeep-openbooleanfalse
dividerdividerbooleanfalseYes
gapgapbooleanfalseYes
badgebadgestring''
typetype'button' | 'checkbox' | 'radio''button'
checkPositioncheck-position'start' | 'end''end'Yes
presentationpresentation'menuitem' | 'option''menuitem'Yes
roleOverriderole-overridestring''
densitydensity0 | -1 | -2 | -3 | -40Yes

Slots

SlotDescription
leading-icon
trailing-icon

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-menu-item-heightItem height (default 48px)
--md-menu-item-padding-inlineHorizontal padding (default 12px)
--md-menu-item-gapGap between elements (default 12px)
--md-menu-item-icon-sizeLeading/trailing icon (default 24px,
--md-menu-item-check-sizeSelection check/radio glyph (default 20px)

CSS Shadow Parts

Style internal elements through shadow DOM with ::part():

PartDescription
state-layer
leading-icon
content
headline
supporting-text
badge
trailing

A labelled section within the menu — groups related rows under a heading so a long menu stays scannable.

  • An empty label defeats the point. An unlabelled group adds nothing over flat rows — either provide a heading, or use divider on an item instead.
  • Pair it with the menu’s layout="grouped". Without it the spacing between sections doesn’t render as intended.
  • Its only theming knob is --md-menu-group-gap, set on the menu — it exposes no custom properties, no events, no methods and no CSS parts of its own.
  • The default slot accepts md-menu-item and md-sub-menu-item children.

Properties

PropertyAttributeTypeDefaultReflects
labellabelstring''
densitydensity0 | -1 | -2 | -3 | -40Yes

Slots

SlotDescription
(default)

A menu row that opens a nested submenu — the same anatomy as md-menu-item, plus a submenu slot holding the nested content.

  • Nothing selectable here. Unlike md-menu-item there is no type, no selected, no keep-open — a submenu row is a branch, not an option. If you are reaching for those, you want md-menu-item.
  • collapse() exists; there is no expand(). Opening is driven by user interaction; collapse() closes the branch and resets its visual state, and an ancestor menu calls it on close so reopening always starts fresh.
  • mdClick fires for the row itself (not the submenu) and carries no payload.
  • The row exposes no --md-sub-menu-item-* custom properties. It inherits the --md-menu-item-* metrics from the menu.

Properties

PropertyAttributeTypeDefaultReflects
headlineheadlinestring''
supportingTextsupporting-textstring''
disableddisabledbooleanfalseYes
dividerdividerbooleanfalseYes
gapgapbooleanfalseYes
badgebadgestring''
densitydensity0 | -1 | -2 | -3 | -40Yes

Methods

MethodParameters
collapse()none

Slots

SlotDescription
leading-icon
submenu

CSS Shadow Parts

Style internal elements through shadow DOM with ::part():

PartDescription
state-layer
leading-icon
content
headline
supporting-text
badge
  • The menu manages roving focus, typeahead, Escape to close, and returns focus to the anchor.
  • Wire the trigger yourself with aria-haspopup="menu" (or listbox in listbox mode) and aria-expanded.
  • Provide list-label when the menu’s purpose isn’t obvious from the trigger.
  • A closed menu is inert and aria-hidden — don’t fight that; it’s what keeps hidden items out of the accessibility tree.
  • auto-focus="false" is for composites where focus must stay in a text field. In that case you must manage the announcements yourself, and note that ARIA IDREFs can’t cross shadow boundaries.
KeyAction
/ Move between enabled rows, wrapping at the ends
Home / EndFirst / last row
A–ZTypeahead — jumps to the next row whose headline starts with the letters
Enter / SpaceActivate the row (closes unless keep-open / persistent)
On a submenu row, open it and move into it (inverted under dir="rtl")
Close the submenu and return to its parent row
EscapeClose, returning focus to the anchor
TabClose and move on — a menu is not a tab stop
Roving focus, typeahead, Escape to close Open in Storybook
Open and drive it from the keyboard
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="a11y-t" variant="outlined">Open and drive it from the keyboard</md-button>
<md-menu id="a11y-m" anchor="a11y-t" list-label="Document actions">
  <md-menu-item headline="Rename" icon="edit"></md-menu-item>
  <md-menu-item headline="Duplicate" icon="content_copy"></md-menu-item>
  <md-menu-item headline="Download" icon="download" disabled></md-menu-item>
  <md-sub-menu-item headline="Share" icon="share">
    <md-menu slot="submenu">
      <md-menu-item headline="Copy link"></md-menu-item>
      <md-menu-item headline="Email"></md-menu-item>
    </md-menu>
  </md-sub-menu-item>
  <md-menu-item headline="Delete" icon="delete"></md-menu-item>
</md-menu>

<script type="module">
  document.getElementById('a11y-t').addEventListener('click', () => {
    document.getElementById('a11y-m').show();
  });
</script>

RTLplacement is logical (bottom-start follows reading order) and the surface mirrors under dir="rtl", including the submenu open direction and the arrow keys that drive it. See RTL.

Same markup, dir=ltr vs dir=rtl Open in Storybook
ltr
Actions
rtl
إجراءات
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display:grid;grid-template-columns:auto 1fr;gap:20px 16px;align-items:center;">
  <span style="inline-size:2.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">ltr</span>
  <div dir="ltr">
    <md-button id="rtl-a" variant="outlined">Actions</md-button>
    <md-menu id="rtl-ma" anchor="rtl-a" placement="bottom-start">
      <md-menu-item headline="Rename" icon="edit"></md-menu-item>
      <md-sub-menu-item headline="Share" icon="share"><md-menu slot="submenu"><md-menu-item headline="Copy link"></md-menu-item></md-menu></md-sub-menu-item>
    </md-menu>
  </div>
  <span style="inline-size:2.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">rtl</span>
  <div dir="rtl">
    <md-button id="rtl-b" variant="outlined">إجراءات</md-button>
    <md-menu id="rtl-mb" anchor="rtl-b" placement="bottom-start">
      <md-menu-item headline="إعادة تسمية" icon="edit"></md-menu-item>
      <md-sub-menu-item headline="مشاركة" icon="share"><md-menu slot="submenu"><md-menu-item headline="نسخ الرابط"></md-menu-item></md-menu></md-sub-menu-item>
    </md-menu>
  </div>
</div>

<script type="module">
  document.getElementById('rtl-a').addEventListener('click', () => {
    document.getElementById('rtl-ma').show();
  });
  document.getElementById('rtl-b').addEventListener('click', () => {
    document.getElementById('rtl-mb').show();
  });
</script>

Densitydensity="-1…-4" compacts rows and cascades to items; a row’s own density overrides the menu’s. See Density.

Rows at every density rung — open each to compare
0 -1 -2 -3 -4
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: flex; gap: 12px; flex-wrap: wrap;">
  <md-button id="dn-0" variant="outlined">0</md-button>
  <md-menu id="dn-m0" anchor="dn-0" density="0"><md-menu-item headline="Rename" icon="edit"></md-menu-item><md-menu-item headline="Duplicate" icon="content_copy"></md-menu-item><md-menu-item headline="Delete" icon="delete"></md-menu-item></md-menu>

  <md-button id="dn-1" variant="outlined">-1</md-button>
  <md-menu id="dn-m1" anchor="dn-1" density="-1"><md-menu-item headline="Rename" icon="edit"></md-menu-item><md-menu-item headline="Duplicate" icon="content_copy"></md-menu-item><md-menu-item headline="Delete" icon="delete"></md-menu-item></md-menu>

  <md-button id="dn-2" variant="outlined">-2</md-button>
  <md-menu id="dn-m2" anchor="dn-2" density="-2"><md-menu-item headline="Rename" icon="edit"></md-menu-item><md-menu-item headline="Duplicate" icon="content_copy"></md-menu-item><md-menu-item headline="Delete" icon="delete"></md-menu-item></md-menu>

  <md-button id="dn-3" variant="outlined">-3</md-button>
  <md-menu id="dn-m3" anchor="dn-3" density="-3"><md-menu-item headline="Rename" icon="edit"></md-menu-item><md-menu-item headline="Duplicate" icon="content_copy"></md-menu-item><md-menu-item headline="Delete" icon="delete"></md-menu-item></md-menu>

  <md-button id="dn-4" variant="outlined">-4</md-button>
  <md-menu id="dn-m4" anchor="dn-4" density="-4"><md-menu-item headline="Rename" icon="edit"></md-menu-item><md-menu-item headline="Duplicate" icon="content_copy"></md-menu-item><md-menu-item headline="Delete" icon="delete"></md-menu-item></md-menu>
</div>

<script type="module">
  document.getElementById('dn-0').addEventListener('click', () => {
    document.getElementById('dn-m0').show();
  });
  document.getElementById('dn-1').addEventListener('click', () => {
    document.getElementById('dn-m1').show();
  });
  document.getElementById('dn-2').addEventListener('click', () => {
    document.getElementById('dn-m2').show();
  });
  document.getElementById('dn-3').addEventListener('click', () => {
    document.getElementById('dn-m3').show();
  });
  document.getElementById('dn-4').addEventListener('click', () => {
    document.getElementById('dn-m4').show();
  });
</script>

Rows go 48px at 0 down to 32px at -4, and the icons taper 24 → 20px with them. --md-menu-item-height and --md-menu-item-icon-size opt a menu out of that curve entirely.

i18n — translate empty-text, list-label, header content and every item’s text. Menu width follows content, so longer translations widen the surface — check --md-menu-max-width.

Localised rows, list label and empty text Open in Storybook
Aktionen Leer
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="i18-de" variant="outlined">Aktionen</md-button>
<md-menu id="i18-mde" anchor="i18-de" list-label="Dokumentaktionen" empty-text="Keine Aktionen">
  <md-menu-item headline="Umbenennen" icon="edit"></md-menu-item>
  <md-menu-item headline="Vervielfältigen" icon="content_copy" supporting-text="Erstellt eine Kopie am selben Ort"></md-menu-item>
  <md-menu-item headline="Löschen" icon="delete"></md-menu-item>
</md-menu>

<md-button id="i18-empty" variant="outlined">Leer</md-button>
<md-menu id="i18-mempty" anchor="i18-empty" empty-text="Keine Aktionen verfügbar"></md-menu>

<script type="module">
  document.getElementById('i18-de').addEventListener('click', () => {
    document.getElementById('i18-mde').show();
  });
  document.getElementById('i18-empty').addEventListener('click', () => {
    document.getElementById('i18-mempty').show();
  });
</script>
Custom propertyPurposeDefault
--md-menu-min-width / --md-menu-max-widthSurface width bounds112px / viewport-clamped
--md-menu-inline-surface-colorInline (embedded) surface tonetransparent
--md-menu-inline-fill-radius-endInline surface corner0
--md-menu-viewport-marginGap from the viewport edge
--md-menu-section-gapSpace between groups2px
--md-menu-group-gapSpace after a md-menu-item-group2px
--md-menu-item-heightRow height (opts out of density)48px, density-derived
--md-menu-item-padding-inlineRow padding12px
--md-menu-item-gapGap between row elements12px
--md-menu-item-icon-sizeLeading / trailing glyph24px (20px in standard/vibrant)
--md-menu-item-check-sizeSelection check / radio glyph20px
Width, row height, padding, icon size and section gaps
Wide and roomy Narrow and dense Grouped, wide gaps
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="th-1" variant="outlined">Wide and roomy</md-button>
<md-menu id="th-m1" anchor="th-1" style="--md-menu-min-width: 260px; --md-menu-item-height: 56px; --md-menu-item-padding-inline: 20px; --md-menu-item-gap: 16px;">
  <md-menu-item headline="Rename" icon="edit"></md-menu-item>
  <md-menu-item headline="Duplicate" icon="content_copy"></md-menu-item>
  <md-menu-item headline="Delete" icon="delete"></md-menu-item>
</md-menu>

<md-button id="th-2" variant="outlined">Narrow and dense</md-button>
<md-menu id="th-m2" anchor="th-2" style="--md-menu-min-width: 140px; --md-menu-max-width: 180px; --md-menu-item-height: 34px; --md-menu-item-icon-size: 18px; --md-menu-item-gap: 8px;">
  <md-menu-item headline="Rename" icon="edit"></md-menu-item>
  <md-menu-item headline="Duplicate" icon="content_copy"></md-menu-item>
  <md-menu-item headline="Delete" icon="delete"></md-menu-item>
</md-menu>

<md-button id="th-3" variant="outlined">Grouped, wide gaps</md-button>
<md-menu id="th-m3" anchor="th-3" layout="grouped" style="--md-menu-section-gap: 12px; --md-menu-group-gap: 12px;">
  <md-menu-item-group label="Order">
    <md-menu-item headline="Newest" type="radio" selected></md-menu-item>
    <md-menu-item headline="Oldest" type="radio"></md-menu-item>
  </md-menu-item-group>
  <md-menu-item-group label="Field">
    <md-menu-item headline="Name" type="radio"></md-menu-item>
    <md-menu-item headline="Size" type="radio"></md-menu-item>
  </md-menu-item-group>
</md-menu>

<script type="module">
  document.getElementById('th-1').addEventListener('click', () => {
    document.getElementById('th-m1').show();
  });
  document.getElementById('th-2').addEventListener('click', () => {
    document.getElementById('th-m2').show();
  });
  document.getElementById('th-3').addEventListener('click', () => {
    document.getElementById('th-m3').show();
  });
</script>

The menu exposes three parts; the rows expose their own seven.

PartOnElement
surfacemd-menuThe elevated popup surface
menu-viewportmd-menuThe scrolling region inside the surface
empty-textmd-menuThe empty-text message
state-layermd-menu-itemHover / focus / press overlay
leading-iconmd-menu-itemLeading glyph container
contentmd-menu-itemHeadline + supporting-text wrapper
headlinemd-menu-itemPrimary label
supporting-textmd-menu-itemSecondary label
badgemd-menu-itemThe badge
trailingmd-menu-itemTrailing text / icon region
surface, menu-viewport, headline and trailing
Styled parts Styled empty state
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<style>
  .parts-menu::part(surface) {
  box-shadow: none;
  border: 2px solid var(--md-sys-color-primary);
  border-radius: 4px;
  }
  .parts-menu::part(menu-viewport) { padding-block: 6px; }
  .parts-menu md-menu-item::part(headline) { text-transform: uppercase; letter-spacing: .06em; font-size: 13px; }
  .parts-menu md-menu-item::part(trailing) { opacity: .6; font-variant-numeric: tabular-nums; }
</style>
<md-button id="pm-t" variant="outlined">Styled parts</md-button>
<md-menu id="pm-m" class="parts-menu" anchor="pm-t">
  <md-menu-item headline="Rename" icon="edit" trailing-text="⌘R"></md-menu-item>
  <md-menu-item headline="Duplicate" icon="content_copy" trailing-text="⌘D"></md-menu-item>
  <md-menu-item headline="Delete" icon="delete" trailing-text="⌫"></md-menu-item>
</md-menu>

<md-button id="pm-t2" variant="outlined">Styled empty state</md-button>
<md-menu id="pm-m2" class="parts-menu" anchor="pm-t2" empty-text="Nothing here yet"></md-menu>

<script type="module">
  document.getElementById('pm-t').addEventListener('click', () => {
    document.getElementById('pm-m').show();
  });
  document.getElementById('pm-t2').addEventListener('click', () => {
    document.getElementById('pm-m2').show();
  });
</script>
md-menu::part(surface) {
box-shadow: none;
border: 1px solid var(--md-sys-color-outline-variant);
}

md-menu-item · md-menu-item-group · md-sub-menu-item · md-select · md-multi-select · md-autocomplete · md-toolbar · md-split-button

For AI Agents — md-menu

Two artefacts to give your AI agent so it generates correct UI with this component. The per-component spec answers "how do I use this exact tag?". The main-llm spec answers "which tag should I pick in the first place?".

Per-component

md-menu spec card

Identity · when to use / when NOT · decision cues · behavioural contract · do/don't · anti-patterns · full API. Paste into your agent when you're implementing with this component.

Main-LLM spec

AWC UI Operator's Manual

System-prompt preamble · decision matrix · token reference · page recipes · anti-patterns. Paste into the system prompt at the start of a piece of work.

Open spec

md-menu readme.md

# md-menu

<!-- llm:meta
tag: md-menu
category: navigation
status: md3-mapped
m3-guidelines: https://m3.material.io/components/menus/guidelines
form-associated: false
depends-on: none
used-by: md-autocomplete, md-date-picker, md-multi-select, md-select
accepts-children: md-menu-item, md-menu-item-group, md-sub-menu-item
-->

**A temporary set of actions or options, anchored to a trigger.** Handles
placement, viewport flipping and clamping, roving focus, typeahead and
dismissal. It is also the dropdown surface inside `md-select`,
`md-multi-select`, `md-autocomplete` and `md-date-picker`.

> Setup, theming, density and i18n are configured once for the whole library —
> see the [AWC UI documentation](https://awc-ui.dev).

---

## When to use

- A **temporary** set of actions from a trigger: overflow menus, context menus,
  row actions.
- More options than fit comfortably on screen — M3 notes a menu takes less space
  than a set of radio buttons or chips.
- Nested action groups (`md-sub-menu-item`).

## When NOT to use

| Situation | Use instead |
|---|---|
| Actions that should always be visible | `md-toolbar` |
| Choosing a value in a form | `md-select` / `md-multi-select` |
| 2–5 exclusive options | `md-segmented-button-set` |
| A list of records | `md-list` |
| A blocking decision | `md-dialog` |
| Supplementary panel content | `md-side-sheet` / `md-bottom-sheet` |
| Top-level navigation | `md-navigation-bar` / `md-navigation-rail` |
| One default action plus variants | `md-split-button` |

## Decision cues

| Need | Setting |
|---|---|
| MD3 baseline surface | `variant="baseline"` (default) |
| M3 Expressive rounded surface | `variant="standard"` |
| Higher emphasis — use sparingly | `variant="vibrant"` |
| Grouped section cards | `variant="standard"` or `"vibrant"` + `layout="grouped"` + `use-gap` |
| Match the trigger's width | `match-anchor-width` |
| Stay open across outside clicks | `persistent` |
| Stay open after one row is clicked | `keep-open` on that `md-menu-item` |
| Don't move focus into the menu on open | `auto-focus="false"` |
| Option semantics instead of menu semantics | `listbox` |
| Cap the scrollable height | `max-height` |
| Near-full-width below 600px | `responsive` |
| No open/close animation | `quick` |
| Message when every row is filtered out | `empty-text` |

## API contract

```html
<md-icon-button id="trigger" icon="more_vert" aria-label="More actions"></md-icon-button>

<md-menu
  anchor="trigger"                              <!-- id of the trigger; default "" -->
  open                                          <!-- default: false -->
  placement="bottom-start|bottom-end|top-start|top-end"  <!-- default: bottom-start -->
  variant="baseline|standard|vibrant"           <!-- default: baseline -->
  layout="standard|grouped"                     <!-- default: standard; ignored when variant="baseline" -->
  use-gap                                       <!-- default: false -->
  match-anchor-width                            <!-- default: false -->
  responsive                                    <!-- default: false -->
  persistent                                    <!-- default: false -->
  quick                                         <!-- default: false -->
  auto-focus="true"                             <!-- default: true -->
  listbox                                       <!-- default: false -->
  max-height="320"                              <!-- number = px, or any CSS length like "50vh"; default: unset -->
  empty-text="No actions"                       <!-- default: "" (empty state disabled) -->
  density="-1|-2|-3|-4"                         <!-- default: 0 (uncompacted; there is no density="0" rule) -->
>
  <md-menu-item headline="Rename"></md-menu-item>
  <md-menu-item headline="Duplicate"></md-menu-item>
  <md-menu-item headline="Delete" divider></md-menu-item>
</md-menu>
```

**Events** — `mdOpen`, `mdClose`, both `CustomEvent<void>` and both declared
`bubbles: false, composed: false`. They fire **only on the `md-menu` element
itself**: they do not bubble up the light DOM and do not cross a shadow
boundary. Always `menu.addEventListener('mdOpen', …)` — a listener on a parent
element or on a host that embeds the menu will never fire.

**Methods** — `show(opts?: { autoFocus?: boolean })`, `close()`,
`reposition()`, `getScrollViewport()`, `setComboboxElement(el)`,
`setVirtualProvider(provider)`. All return promises.

**Slots** — `(default)`: `md-menu-item` / `md-menu-item-group` /
`md-sub-menu-item` children. `header`: pinned content rendered above the
scroll area (used by the filterable pickers for their search field).

**Parts** — `surface`, `menu-viewport`, `empty-text`.

**Integration-only props — never set these by hand:** `list-label` is accepted
but the menu renders it into no ARIA attribute, so it does not label anything on
its own; the composite pickers set it. `setComboboxElement()` and
`setVirtualProvider()` are the virtualization hooks those pickers use.

### Behavioral contract worth knowing

- **`anchor` is an element `id`, resolved in the menu's own root node first,
  then in the document.** A menu and its trigger inside the same shadow root
  work; an id that lives in a *different* shadow root is unreachable and the
  menu will not position.
- **The menu writes ARIA onto the anchor for you** while it is open:
  `aria-haspopup="menu"` (or `"listbox"` in `listbox` mode), `aria-controls`
  (menu mode only), and `aria-expanded` — but `aria-expanded` is only written
  when the anchor's role supports it (an explicit `role` of button, combobox,
  link, menuitem, tab, treeitem, checkbox, gridcell, row, rowheader or
  columnheader, or a native `<button>`, `<summary>` or `<a href>`). A roleless
  custom-element trigger deliberately gets none.
- **A closed menu stays in the DOM**, at `opacity: 0` with `aria-hidden="true"`
  and `inert`. Don't try to focus into a closed menu; nothing inside it is
  tabbable.
- **Auto-dismiss is on by default.** The menu closes on an outside click, and
  when another non-persistent top-level menu opens. `persistent` opts out of
  both. Ancestors and descendants of the menu are never dismissed this way, so
  submenus are unaffected regardless.
- `Escape` closes and returns focus to the anchor. `Tab` closes the **whole**
  menu tree and also returns focus to the anchor.
- `close()` plays a 150 ms close animation before `open` flips to `false`;
  `quick` makes it immediate.
- On open, focus moves to the first item unless `auto-focus="false"` or
  `show({ autoFocus: false })`. The visible focus ring only appears when the
  open was keyboard-initiated; a mouse-opened menu focuses ring-less until the
  user presses a key.
- Typeahead: single printable characters build a 500 ms buffer and jump to the
  first item whose `headline` (falling back to its text content) starts with it.
- **Every menu without a `md-sub-menu-item` scrolls** — with or without
  `max-height`. The body is wrapped in the `menu-viewport` scroll container and
  bounded by `max-height`, the space on the chosen side, and the viewport. A
  menu that *does* contain a submenu keeps `overflow: visible` instead, so
  `max-height` has no effect there.
- `empty-text` renders a `role="status"` message, and only when it is set **and**
  no visible item remains. Items hidden with the `hidden` attribute or inline
  `style="display: none"` count as hidden — that is the type-to-filter contract.
- **Group cards need three settings together**: `variant="standard"` or
  `"vibrant"`, `layout="grouped"`, and `use-gap`. `layout` is ignored entirely
  while `variant="baseline"`.
- While open, the menu tracks scroll, resize, its own surface size AND the
  anchor moving (per-frame rect watch — covers a transform-animated ancestor
  like a bottom sheet still sliding in, and sibling reflow shifting the
  trigger). `reposition()` remains for the rare synchronous case where waiting
  for the next frame is too late.
- ⚠️ **Never ship `open` in the initial markup.** Positioning, outside-click and
  scroll dismissal, `mdOpen` and the anchor's ARIA are all wired by the `open`
  change handler, which does not run for an attribute that is already present at
  first render. Such a menu paints unpositioned (the host is `position: fixed`
  with no offsets) floating over the surrounding content, and clicking away will
  not close it. Open it from an interaction instead — `menu.show()`, or set
  `.open = true` from script after mount.

---

## Do / Don't

Sourced from [M3 · Menus · Guidelines](https://m3.material.io/components/menus/guidelines).

| ✅ Do | ❌ Don't |
|---|---|
| Use a menu for a **temporary** set of actions | To show actions at all times, use a toolbar instead |
| Prefer a menu over many radio buttons or chips when space is tight | Don't use a menu where two visible options would do |
| Open on a clear trigger — button, icon, field, right-click, long-press | Don't open menus on hover alone |
| Use `baseline`/`standard` for utilitarian menus | Use `vibrant` sparingly — it's high emphasis |
| Reserve slots for uses that keep the menu accessible and functional | Don't stuff arbitrary interactive content into the menu surface |
| Let the menu float above other UI | Don't clip it inside an `overflow: hidden` ancestor |
| Group related commands | Don't present a flat list of twenty unrelated actions |

---

## Patterns

```html
<!-- Overflow menu -->
<md-icon-button id="more" icon="more_vert" aria-label="More actions"></md-icon-button>
<md-menu id="more-menu" anchor="more" placement="bottom-end">
  <md-menu-item headline="Rename"></md-menu-item>
  <md-menu-item headline="Duplicate"></md-menu-item>
  <md-menu-item headline="Delete" divider></md-menu-item>
</md-menu>

<script type="module">
  const menu = document.getElementById('more-menu');
  document.getElementById('more').addEventListener('mdClick', () => menu.show());

  // mdOpen/mdClose do not bubble and do not cross shadow roots —
  // listen on the menu element itself.
  menu.addEventListener('mdOpen', () => console.log('opened'));
  menu.addEventListener('mdClose', () => console.log('closed'));
</script>
```

```html
<!-- Grouped section cards: variant + layout + use-gap are all required -->
<md-icon-button id="sort-btn" icon="sort" aria-label="Sort"></md-icon-button>
<md-menu anchor="sort-btn" variant="standard" layout="grouped" use-gap>
  <md-menu-item-group label="Order">
    <md-menu-item headline="Newest" type="radio" selected></md-menu-item>
    <md-menu-item headline="Oldest" type="radio"></md-menu-item>
  </md-menu-item-group>
  <md-menu-item-group label="View">
    <md-menu-item headline="Compact" type="checkbox"></md-menu-item>
  </md-menu-item-group>
</md-menu>
```

```html
<!-- Column toggles: the menu survives outside clicks AND row clicks -->
<md-icon-button id="cols" icon="view_column" aria-label="Columns"></md-icon-button>
<md-menu anchor="cols" persistent>
  <md-menu-item headline="Name"  type="checkbox" keep-open selected></md-menu-item>
  <md-menu-item headline="Owner" type="checkbox" keep-open></md-menu-item>
</md-menu>
```

```html
<!-- Type-to-filter with an empty state and a capped, scrolling body -->
<md-text-field id="filter" label="Filter"></md-text-field>
<md-menu id="cmds" anchor="filter" max-height="320" empty-text="No matches" auto-focus="false">
  <md-menu-item headline="Archive"></md-menu-item>
  <md-menu-item headline="Assign"></md-menu-item>
  <md-menu-item headline="Delete"></md-menu-item>
</md-menu>

<script type="module">
  const menu = document.getElementById('cmds');
  document.getElementById('filter').addEventListener('mdInput', (e) => {
    const q = e.detail.toLowerCase();   // mdInput detail is the string value
    for (const item of menu.querySelectorAll('md-menu-item')) {
      item.hidden = !item.headline.toLowerCase().includes(q);   // drives empty-text
    }
    if (!menu.open) menu.show({ autoFocus: false });
  });
</script>
```

```html
<!-- Reposition after a reflow moves the anchor -->
<script type="module">
  const menu = document.getElementById('more-menu');
  new ResizeObserver(() => { if (menu.open) menu.reposition(); })
    .observe(document.getElementById('more'));
</script>
```

## Anti-patterns

| ❌ Wrong | ✅ Right | Why |
|---|---|---|
| `document.addEventListener('mdOpen', …)` or listening on a wrapper element | Listen on the `md-menu` element itself | `mdOpen`/`mdClose` are `bubbles: false, composed: false`. |
| Expecting the menu to stay open on an outside click | `persistent` | It auto-dismisses by default. |
| Expecting the menu to stay open after a row is clicked | `keep-open` on the item | Item activation closes the root menu. |
| `layout="grouped"` on the default `variant="baseline"` | Add `variant="standard"` (or `"vibrant"`) and `use-gap` | `layout` is ignored while the variant is baseline. |
| Nesting `md-menu` inside an `overflow: hidden` container | Let it float | It gets clipped. |
| `anchor` pointing at an id in a different shadow root | Keep the trigger in the same root as the menu, or in the document | The id lookup only searches the menu's own root, then the document. |
| Adding `aria-expanded` / `aria-haspopup` to the trigger by hand | Let the menu write them | It manages them while open and removes `aria-expanded` from roles that can't carry it. |
| `list-label` expecting it to name the popup | Name the list from the trigger | The menu renders it into no ARIA attribute. |
| `max-height` on a menu containing `md-sub-menu-item` | Flatten the menu, or drop the cap | Submenu flyouts must not be clipped, so the cap is not applied. |
| `md-list-item` as menu children | `md-menu-item` | Wrong role and keyboard model. |
| A menu for a form value | `md-select` | Menus are commands; selects are values. |
| Always-visible actions in a menu | `md-toolbar` | M3 explicit rule. |
| `variant="vibrant"` everywhere | Reserve it | M3: high emphasis, use sparingly. |
| Calling `setVirtualProvider` / `setComboboxElement` in app code | Leave them to the composite pickers | Integration hooks. |

## Accessibility, RTL, density, i18n

**Accessibility**
- The surface is `role="menu"` with `aria-orientation="vertical"`, and
  `aria-labelledby` pointing at the `anchor` id — so give the trigger a real
  accessible name. In `listbox` mode the surface becomes `role="presentation"`
  and the owning component supplies the real `listbox`.
- Roving focus, typeahead, `Escape`, `Tab` and focus return to the anchor are all
  handled for you.
- The menu maintains `aria-haspopup` / `aria-controls` / `aria-expanded` on the
  anchor — don't duplicate them by hand.
- A **closed** menu is `inert` and `aria-hidden="true"`; that is what keeps
  hidden rows out of the accessibility tree and out of the tab order.
- `auto-focus="false"` is for composites where focus must stay in a text field.
  Note that ARIA IDREFs cannot cross shadow boundaries, so an
  `aria-activedescendant` relationship has to be built inside one root.

**RTL** — row *content* mirrors (`md-menu-item` lays its regions out with
logical properties, and an `md-sub-menu-item` opens toward the inline start).
The menu's own `placement` does **not**: `-start` / `-end` are mapped straight
onto physical left / right, so `placement="bottom-start"` aligns to the anchor's
physical **left** edge in every direction, and the overflow flip still measures
physical right-hand space. Under `dir="rtl"`, ask for `bottom-end` / `top-end`
when you want the menu aligned to the anchor's inline start.

**Density** — a local override is `density="-1"` through `density="-4"`; `0` is
the uncompacted default and has no rule of its own. The value drives the same
`--md-sys-density-scale` signal a global `data-density` ancestor sets, so a
local rung wins over the inherited one. To reset an inherited rung on a subtree
use `style="--md-sys-density-scale: 0"` — note that the ancestor's
`--md-sys-spacing-*` values still inherit.

**i18n** — translate `empty-text`, `header` slot content and every item's text.
Menu width follows content, so longer translations widen the surface — cap it
with `--md-menu-max-width` if your layout depends on it.

## Related components

`md-menu-item` · `md-menu-item-group` · `md-sub-menu-item` · `md-select` ·
`md-multi-select` · `md-autocomplete` · `md-toolbar` · `md-split-button`

## Theming

| Custom property | Purpose | Default |
|---|---|---|
| `--md-menu-min-width` | Minimum surface width | `112px` |
| `--md-menu-max-width` | Maximum surface width (still clamped to the viewport) | `100vw` |
| `--md-menu-viewport-margin` | Shrinks the surface's `max-width` / `max-height` to the viewport minus twice this value. It does **not** move the surface — the positional gap kept from each viewport edge is a fixed `8px` and is not themeable. | `8px` |
| `--md-menu-group-gap` | Space between grouped section cards | `4px` |
| `--md-menu-section-gap` | Space below a `gap` child in the baseline variant | `2px` |
| `--md-menu-inline-surface-color` | Background for the inline-fill embed only | `transparent` |
| `--md-menu-inline-fill-radius-end` | Bottom corner radius for the inline-fill embed | `0` |

**CSS parts** — `surface` (the floating panel), `menu-viewport` (the scroll
container, present on every non-submenu menu), `empty-text` (the empty-state
message).

```css
md-menu {
  --md-menu-min-width: 200px;
  --md-menu-max-width: 360px;
}
md-menu::part(surface) {
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
}
```

<!-- Auto Generated Below -->


## Properties

| Property           | Attribute            | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | Type                                                         | Default          |
| ------------------ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ---------------- |
| `anchor`           | `anchor`             | ID of the anchor element to position relative to.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | `string`                                                     | `''`             |
| `autoFocus`        | `auto-focus`         | When false, opening the menu initializes roving tabindex without moving focus to the first item. The consumer handles focus (e.g. docked pickers that scroll to the selected option). `show({ autoFocus: false })` still takes precedence for a single open.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | `boolean`                                                    | `true`           |
| `density`          | `density`            | Local density rung. Drives the same `--md-sys-density-scale` signal that a global `data-density` ancestor sets, so a local value simply overrides the inherited one. 0 = default, -4 = ultra-compact.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `-1 \| -2 \| -3 \| -4 \| 0`                                  | `0`              |
| `emptyText`        | `empty-text`         | Message shown when all menu items are hidden (e.g. no filter results). Empty string disables.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | `string`                                                     | `''`             |
| `layout`           | `layout`             | Layout style for vertical menus. 'standard' is flat, 'grouped' uses visual section separators.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `"grouped" \| "standard"`                                    | `'standard'`     |
| `listLabel`        | `list-label`         |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | `string \| undefined`                                        | `undefined`      |
| `listbox`          | `listbox`            | Present the popup as a WAI-ARIA listbox (surface `role="presentation"`, anchor `aria-haspopup="listbox"`) rather than a menu. `md-select` sets this for both its virtual and non-virtual lists so the popup is a listbox of options regardless of size. (The virtual path additionally registers a provider, which adds the aria-activedescendant focus model.)                                                                                                                                                                                                                                                                                                                                                                                                                                                       | `boolean`                                                    | `false`          |
| `matchAnchorWidth` | `match-anchor-width` | When true, the menu surface min-width matches the anchor element's width. The menu still grows wider if items need more space.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `boolean`                                                    | `false`          |
| `maxHeight`        | `max-height`         | Cap the menu surface block-size. When the items exceed it the list scrolls vertically inside a plain scroll viewport. A number is treated as pixels; any CSS length is passed through (e.g. `'50vh'`). Ignored when the menu has submenus — their flyouts must not be clipped by an overflow container.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | `number \| string \| undefined`                              | `undefined`      |
| `open`             | `open`               | Whether the menu is open.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | `boolean`                                                    | `false`          |
| `persistent`       | `persistent`         | When set, the menu does NOT auto-dismiss in response to interaction elsewhere on the page. By default a menu closes when:   1. The user clicks outside the menu and its anchor.   2. Another non-persistent menu opens (single-open coordination —      opening menu B closes menu A so two top-level menus never      overlap each other on screen).  `persistent` opts out of both auto-dismiss paths. The menu then only closes via `close()`, the anchor's toggle, item activation, or Escape. Useful for modal-style pickers, multi-step menus, and any context where the consumer manages dismissal explicitly.  Submenus (an `<md-menu>` rendered inside a parent menu's `submenu` slot) are never affected by sibling-menu dismissal even without this flag — ancestors and descendants are always preserved. | `boolean`                                                    | `false`          |
| `placement`        | `placement`          | Position of the menu relative to the anchor.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | `"bottom-end" \| "bottom-start" \| "top-end" \| "top-start"` | `'bottom-start'` |
| `quick`            | `quick`              | Skip open/close animation.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `boolean`                                                    | `false`          |
| `responsive`       | `responsive`         | Adapt to compact viewports. The menu is *always* clamped within the viewport (it never overflows the screen and pins to the edges with a margin). When `responsive` is set, on viewports at or below the compact breakpoint (600px) the menu additionally expands to near-full-width below the trigger for comfortable touch use.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | `boolean`                                                    | `false`          |
| `useGap`           | `use-gap`            | Use gap separators instead of dividers between groups (vertical menu only).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | `boolean`                                                    | `false`          |
| `variant`          | `variant`            | Visual variant. - 'baseline': MD3 baseline menu (square corners, surface container) - 'standard': M3 Expressive vertical menu (rounded, surface-based colors) - 'vibrant': M3 Expressive vertical menu (rounded, tertiary-based colors)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | `"baseline" \| "standard" \| "vibrant"`                      | `'baseline'`     |


## Events

| Event     | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | Type                |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
| `mdClose` | Fires when the menu closes. Scoped like `mdOpen` — see its note.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | `CustomEvent<void>` |
| `mdOpen`  | Fires when the menu opens. Scoped to the menu element: `bubbles: false` stops it climbing the light-DOM tree, and `composed: false` stops it escaping the shadow root of a host that embeds this menu (md-select, md-multi-select, md-date-picker …). Without `composed: false` a `composed` event still surfaces at the embedding host as an `AT_TARGET` event — so a consumer's `mdOpen` listener on the wrapper would fire for both the wrapper's own event and this inner one (duplicate open/close). Wrappers listen via `onMdOpen` on the menu element itself, which still fires regardless. | `CustomEvent<void>` |


## Methods

### `close() => Promise<void>`

Closes the menu programmatically.

#### Returns

Type: `Promise<void>`



### `getScrollViewport() => Promise<HTMLElement | null>`

The scrollable viewport element (the menu's `.md-menu__scroll-shadow`
scroll div), or null when the menu isn't capped/scrollable. A virtualized
host attaches its scroll listener and reads `scrollTop`/`clientHeight`
from this.

#### Returns

Type: `Promise<HTMLElement | null>`



### `reposition() => Promise<void>`

Recompute the menu's position against its anchor immediately. While open,
md-menu already tracks scroll, resize, its own surface size AND the anchor
moving (a per-frame rect watch — covers transform-animated ancestors like
a sliding bottom sheet, and sibling reflow shifting the trigger), so this
is rarely needed; call it when the next frame is too late — e.g. to avoid
a one-frame lag right after a synchronous layout change.

#### Returns

Type: `Promise<void>`



### `setComboboxElement(el: HTMLElement | null) => Promise<void>`

Register (or clear with `null`) the combobox element that holds focus while
a virtual provider drives the list. When set, keyboard navigation manages
`aria-activedescendant` on it rather than calling `.focus()` on options —
the canonical pattern for a listbox whose rows are virtualized/recycled.

#### Parameters

| Name | Type                  | Description |
| ---- | --------------------- | ----------- |
| `el` | `HTMLElement \| null` |             |

#### Returns

Type: `Promise<void>`



### `setVirtualProvider(provider: VirtualMenuProvider | null) => Promise<void>`

Register (or clear with `null`) a virtual provider. With a provider set,
keyboard nav, typeahead, and roving focus run against the data model so a
windowed list of millions of options behaves like a normal menu. Without
one the menu is unchanged.

#### Parameters

| Name       | Type                          | Description |
| ---------- | ----------------------------- | ----------- |
| `provider` | `VirtualMenuProvider \| null` |             |

#### Returns

Type: `Promise<void>`



### `show(opts?: { autoFocus?: boolean; }) => Promise<void>`

Opens the menu programmatically. Pass `{ autoFocus: false }` to keep focus on the caller (e.g. a text field).

#### Parameters

| Name   | Type                                                 | Description |
| ------ | ---------------------------------------------------- | ----------- |
| `opts` | `{ autoFocus?: boolean \| undefined; } \| undefined` |             |

#### Returns

Type: `Promise<void>`




## Shadow Parts

| Part              | Description |
| ----------------- | ----------- |
| `"empty-text"`    |             |
| `"menu-viewport"` |             |
| `"surface"`       |             |


## Dependencies

### Used by

 - [md-autocomplete](../md-autocomplete)
 - [md-date-picker](../md-date-picker)
 - [md-multi-select](../md-multi-select)
 - [md-select](../md-select)

### Graph
```mermaid
graph TD;
  md-autocomplete --> md-menu
  md-date-picker --> md-menu
  md-multi-select --> md-menu
  md-select --> md-menu
  style md-menu fill:#f9f,stroke:#333,stroke-width:4px
```

----------------------------------------------

*Built with [StencilJS](https://stenciljs.com/)*

AWC UI Operator’s Manual

# main-llm.md — AWC UI build director

<!-- llm:meta
role: director
audience: llm
library: "@awc-ui/core"
component-count: 57
sub-component-count: 24
manual-count: 81
per-component-docs: ./packages/core/src/components/<tag>/readme.md
-->

**You are building a web app with AWC UI, a Material Design 3 web-component
library.** This file is the entry point and the only document you need before
writing UI code. Everything here is self-contained: tokens, recipes,
composition rules and the ship checklist. Per-component detail lives in
`./packages/core/src/components/<tag>/readme.md` — e.g.
[`md-button`](./packages/core/src/components/md-button/readme.md).

Your job, in order:

1. **Interview** the user (§1). Do not skip it and do not guess.
2. **Lock the configuration** their answers imply (§2) and bootstrap it (§3–§4).
3. **Route every UI need through the decision matrix** (§5) — never pick a
   component by name-similarity.
4. **Load the component's readme.md before writing a single line of its
   markup** (§6). The file is
   `./packages/core/src/components/<tag>/readme.md` — read it in full, do not
   skim it, and do not write the markup from memory of a similar library. Each
   one has a `When NOT to use`, a `Do / Don't` table sourced from
   [m3.material.io](https://m3.material.io), and an `Anti-patterns` table of
   mistakes models actually make. If you are about to use three components,
   load all three readmes first.
5. **Check what nests inside what** (§7) and start from a recipe (§8) rather
   than from a blank page.
6. **Apply the universal rules** (§9) — the API, content and accessibility
   rules every component is bound by, and the ones §10 checks you against.
7. **Run the ship checklist** (§10) before declaring done.

**Fail closed.** If you cannot satisfy a step — no component fits, a token
doesn't exist, an accessible name has nowhere to come from — say so and ask.
Do not invent an `md-*` tag, a prop, or a token. If it is not in this file or
in the component's manual, it does not exist.

---

## §1 — Interview the user

Ask these **one at a time**, in this order. Each answer closes off decisions
downstream, so don't batch them into a wall of questions. Skip a question only
if the user has already answered it unprompted.

If the user says "just pick sensible defaults", use the **bold** option and tell
them what you chose.

### 1.1 Scope and shape

1. **What is the app?** One or two sentences — domain, primary job, who uses it.
2. **What kind of surface is it?**
   - Internal tool / admin console / dashboard
   - Data-heavy CRUD application
   - Consumer-facing product
   - Marketing or content site
   - Mobile-first / PWA
3. **Which framework?** React · Angular · Vue · Svelte · **plain HTML** ·
   Next · Nuxt · SvelteKit · Astro
4. **Does it server-render?** (SSR/SSG, or **client-only SPA**)
5. **Roughly how many distinct screens**, and what are the top 3?

### 1.2 Look and feel

6. **Density** — how much information per screen?
   - `0` — **default**, comfortable, touch-friendly
   - `-1` / `-2` — compact; typical for admin consoles
   - `-3` / `-4` — ultra-compact; dense data tables, trading/ops screens
7. **Theme** — light only, dark only, or **both with a user toggle**?
   Does it follow the OS preference?
8. **Brand color** — a seed/primary color, or **stock MD3 palette**?
9. **Expressive motion** — keep **ripple and shape-morph on** (default), or turn
   them off for a flatter, more utilitarian feel?
10. **Shape language** — **rounded** (MD3 default) or squared?

### 1.3 Internationalization

11. **How many locales**, and which?
12. **Any RTL locales** (Arabic, Hebrew, Farsi, Urdu)? — this changes layout
    verification and directional-icon handling.
13. **Which i18n engine?** (i18next, vue-i18n, ngx-translate, Paraglide, custom)
    Components are engine-agnostic — you localize in the consumer layer.
14. **Locale-formatted values** — dates, numbers, currency? Which locale drives
    `Intl`?

### 1.4 Data and forms

15. **Is there significant tabular data?** How many rows, and is it
    server-paged? (Drives how you page `md-table` — it holds the state, you
    supply each page of rows.)
16. **Are there charts?** Which questions should they answer?
17. **How heavy are the forms?** Validation rules, async validation, multi-step?
18. **Rich text editing anywhere?** — ⚠️ **AWC UI has no RTE component.** If yes,
    you must integrate a third-party editor (TipTap, Lexical, Quill) and style
    it to the MD3 tokens yourself. Confirm this with the user explicitly.

### 1.5 Constraints

19. **Accessibility target** — **WCAG 2.1 AA** (what the library is tested to),
    or stricter?
20. **Browser/device support floor?**
21. **Anything already decided** you must not change — existing design system,
    router, state library, CSS approach?

---

## §2 — Map answers to configuration

| Answer | What you set |
|---|---|
| Admin console / data-heavy | `data-density="-1"` or `-2` on `<html>`; prefer `size="xs"`/`"sm"` on actions |
| Consumer / marketing | leave density alone (`0` is the default); larger button sizes (`md`/`lg`) for CTAs |
| Mobile-first | `md-navigation-bar` + `md-fab`; avoid `md-navigation-rail`, `md-transfer-list`, wide tables |
| Desktop-first | `md-navigation-rail` or `md-app-bar`; rail over bottom bar |
| Dark mode | `data-theme="dark"` on `<html>`; wire a toggle, and mirror OS via `prefers-color-scheme` |
| Both themes with toggle | persist the choice; set the attribute before first paint to avoid a flash |
| Brand color | override the `--md-sys-color-*` roles in your own stylesheet, loaded after the tokens (§4.2) |
| Flat / utilitarian | `data-ripple="off"` and `data-shape-morph="off"` on `<html>` |
| Any RTL locale | `dir="rtl"` on `<html>`; add `mirror-icon` to `md-button`s with directional glyphs; swap the glyph name yourself everywhere else (§4.5) |
| Multiple locales | build a dictionary in the consumer layer and feed component text props from it; never hardcode strings in markup |
| `Intl`-formatted values | pass a `locale` prop where a component exposes one; format everything else before it reaches the component |
| SSR | import from `@awc-ui/core/hydrate` on the server; use the client/server wrappers in `@awc-ui/react` |
| Heavy forms | components are form-associated via `ElementInternals` — use a real `<form>`, `md-button type="submit"`, and native `required` (§4.7) |
| Rich text | integrate a third-party editor; there is no `md-rich-text` |

### 2.1 Global switches — the complete set

All are attributes on `<html>` (or any ancestor; the nearest one wins).

```html
<html
  lang="en"
  dir="ltr"                  <!-- or rtl -->
  data-theme="dark"          <!-- omit for light -->
  data-density="-1"          <!-- -1 … -4; see below -->
  data-ripple="off"          <!-- default on -->
  data-shape-morph="off"     <!-- default on -->
>
```

**`data-density="0"` is inert.** No `[density="0"]` rule exists — density `0` is
simply the base values on `:root`, and a `0` rule would pin them onto every
element (reflected props write `density="0"` almost everywhere) and break global
inheritance. So the *overriding* range is `-1 … -4`. To escape an inherited rung
for one subtree, reset the scale directly:

```css
.opt-out-of-density { --md-sys-density-scale: 0; }
```

Per-component overrides beat the global one: a `density` prop, or
`ripple="off"` / `shape-morph="off"` on the element. **53 of the 57 top-level
components expose a `density` prop.** The four that don't: `md-divider`,
`md-ripple`, `md-sparkline`, `md-tabs`. (Nine sub-components also lack one —
they inherit density from the parent that owns their layout.)

---

## §3 — Install and bootstrap

```bash
npm install @awc-ui/core
```

**Register everything (recommended).** Two imports, once per app entry — one
defines every component, one loads the tokens. Both come from `@awc-ui/core`
itself, so this path needs no second package:

```ts
import { defineCustomElements } from '@awc-ui/core/loader';
import '@awc-ui/core/css/tokens.css';

defineCustomElements(window);
```

`@awc-ui/core/css/tokens.css` is the complete, self-contained token sheet —
light and dark colour roles, shape, elevation, motion, typescale, spacing and
z-index. It is the same set documented in §4.1.

**One-line alternative.** `@awc-ui/core/define` does both steps in a single
import. It loads the package's own token sheet, so it needs nothing else
installed:

```ts
import '@awc-ui/core/define'; // defines every component + loads the token sheet
```

`define` is client-only (it eval-guards `window`) and, because it imports CSS,
it needs a bundler. Never import it into a server graph.

**Per-component registration**, for size-sensitive bundles — load the token
sheet once yourself:

```ts
import '@awc-ui/core/css/tokens.css';
import '@awc-ui/core/components/md-button';
import '@awc-ui/core/components/md-text-field';
```

**Framework wrappers** — use these instead of raw elements; they handle
registration, typed props, and event binding. Below the version floor, drop to
the raw custom elements and the `loader` import above.

| Framework | Package | Requires |
|---|---|---|
| React / Next | `@awc-ui/react` | React 18+ |
| Angular | `@awc-ui/angular` (`AwcUiModule`) | Angular 17+ |
| Vue / Nuxt | `@awc-ui/vue` | Vue 3 |
| Svelte / SvelteKit | `@awc-ui/svelte` | Svelte 4+ |
| Plain HTML / Astro | `@awc-ui/core/loader` | — |

**SSR** — `@awc-ui/core/hydrate` renders Declarative Shadow DOM on the server.
`@awc-ui/react` ships matching client/server wrappers. Keep `define` and
`loader` out of the server graph; both are browser entries.

**Fonts** — components expect Roboto and Material Symbols Outlined to be
available. The library does **not** inject them. Every `icon="…"` prop renders a
Material Symbols glyph inside shadow DOM, so the font must be registered at the
**document** level (font registration crosses shadow boundaries; class rules do
not — the components declare the class rule inside their own roots):

```html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap">
```

To swap the whole library to a different Material Symbols cut, set
`--md-sys-icon-font-family` on `:root` and load the matching `@font-face`.

---

## §4 — Global configuration reference

### 4.1 The token system

All AWC UI styling resolves to design tokens. **Never hardcode hex colours,
pixel radii, shadows, or font stacks** — they break theming, dark mode, density
and RTL in one stroke. Every value below is a real custom property defined by
`@awc-ui/tokens`; you may read them, and you may override them.

#### Color roles

Light is the default; `[data-theme="dark"]` swaps the palette. Every role has a
matching `on-` role for content drawn on top of it.

| Token | Light | Dark | Typical usage |
|---|---|---|---|
| `--md-sys-color-primary` | `#6750A4` | `#D0BCFF` | Filled buttons, active states |
| `--md-sys-color-on-primary` | `#FFFFFF` | `#381E72` | Text/icons on primary |
| `--md-sys-color-primary-container` | `#EADDFF` | `#4F378B` | FAB, selected segments |
| `--md-sys-color-on-primary-container` | `#21005D` | `#EADDFF` | Text on primary-container |
| `--md-sys-color-secondary` | `#625B71` | `#CCC2DC` | Secondary accents |
| `--md-sys-color-secondary-container` | `#E8DEF8` | `#4A4458` | Tonal buttons, chips |
| `--md-sys-color-tertiary` | `#7D5260` | `#EFB8C8` | Tertiary accents |
| `--md-sys-color-tertiary-container` | `#FFD8E4` | `#633B48` | Tertiary surfaces |
| `--md-sys-color-error` | `#B3261E` | `#F2B8B5` | Error / destructive |
| `--md-sys-color-on-error` | `#FFFFFF` | `#601410` | Content on error |
| `--md-sys-color-error-container` | `#F9DEDC` | `#8C1D18` | Error surfaces |
| `--md-sys-color-surface` | `#FFFBFE` | `#1C1B1F` | Page / card background |
| `--md-sys-color-on-surface` | `#1C1B1F` | `#E6E1E5` | Body text |
| `--md-sys-color-surface-variant` | `#E7E0EC` | `#49454F` | Muted fills |
| `--md-sys-color-on-surface-variant` | `#49454F` | `#CAC4D0` | Secondary text, icons |
| `--md-sys-color-surface-container-lowest` | `#FFFFFF` | `#0F0D13` | Lowest surface tier |
| `--md-sys-color-surface-container-low` | `#F7F2FA` | `#1D1B20` | Card / elevated button bg |
| `--md-sys-color-surface-container` | `#F3EDF7` | `#211F26` | Default container tier |
| `--md-sys-color-surface-container-high` | `#ECE6F0` | `#2B2930` | Dialog / menu surfaces |
| `--md-sys-color-surface-container-highest` | `#E6E0E9` | `#36343B` | Highest surface tier |
| `--md-sys-color-outline` | `#79747E` | `#938F99` | Borders (3:1 contrast) |
| `--md-sys-color-outline-variant` | `#CAC4D0` | `#49454F` | Dividers (low contrast) |
| `--md-sys-color-inverse-surface` | `#313033` | `#E6E1E5` | Snackbar, inverse chips |
| `--md-sys-color-inverse-on-surface` | `#F4EFF4` | `#313033` | Content on inverse-surface |
| `--md-sys-color-scrim` | `#000000` | `#000000` | Modal scrims |

**Semantic status roles** — beyond the baseline M3 palette, the library adds
`success`, `warning` and `info`, each with `on-`, `-container` and
`on-…-container` companions, in both themes, AA-verified. Used by `md-chip`,
`md-badge`, `md-meter`, `md-status-dot`.

| Token | Light | Dark |
|---|---|---|
| `--md-sys-color-success` | `#2E6B4F` | `#9DD5B0` |
| `--md-sys-color-success-container` | `#B8F0CE` | `#14512F` |
| `--md-sys-color-warning` | `#7A5900` | `#EFC148` |
| `--md-sys-color-warning-container` | `#FFDF9B` | `#5C4200` |
| `--md-sys-color-info` | `#38608F` | `#A2C9FE` |
| `--md-sys-color-info-container` | `#D3E4FF` | `#1E4975` |

#### Shape tokens

| Token | Value | Typical usage |
|---|---|---|
| `--md-sys-shape-corner-none` | `0px` | Square edges |
| `--md-sys-shape-corner-extra-small` | `4px` | Snackbar, small chips |
| `--md-sys-shape-corner-small` | `8px` | Cards (outlined), small surfaces |
| `--md-sys-shape-corner-medium` | `12px` | Cards (default), inputs |
| `--md-sys-shape-corner-large` | `16px` | Dialogs, large cards |
| `--md-sys-shape-corner-extra-large` | `28px` | Bottom sheets |
| `--md-sys-shape-corner-full` | `9999px` | Buttons, chips, FAB |

Partial-corner variants also exist for surfaces that meet an edge:
`--md-sys-shape-corner-extra-small-top`, `-large-top`, `-large-end`,
`-extra-large-top`. Their values are four-value `border-radius` shorthands
(e.g. `16px 16px 0px 0px`), so assign them to `border-radius`, not to a single
corner.

#### Elevation tokens

`-0` is the keyword `none`; `-1` through `-5` are complete `box-shadow` values,
and those five are **re-declared with deeper shadows under `[data-theme="dark"]`**
— so always use the token rather than copying its value.

| Token | When |
|---|---|
| `--md-sys-elevation-0` | `none` — resting cards, flat surfaces |
| `--md-sys-elevation-1` | Elevated buttons and cards at rest |
| `--md-sys-elevation-2` | Hover bump |
| `--md-sys-elevation-3` | FAB, menus, dialogs at rest |
| `--md-sys-elevation-4` – `-5` | Reserved for the most prominent surfaces |

#### Motion tokens

Durations come in four families of four: `short1…4` (50/100/150/200ms),
`medium1…4` (250/300/350/400ms), `long1…4` (450/500/550/600ms), and
`extra-long1…4` (700/800/900/1000ms).

| Token | Value | When |
|---|---|---|
| `--md-sys-motion-duration-short2` | `100ms` | Buttons, ripples, state layers |
| `--md-sys-motion-duration-medium2` | `300ms` | Dialogs, sheets, menus |
| `--md-sys-motion-duration-long2` | `500ms` | Large surface transitions |
| `--md-sys-motion-duration-extra-long1` | `700ms` | Ambient / chart entrances |
| `--md-sys-motion-easing-standard` | `cubic-bezier(0.2, 0, 0, 1)` | Small utility transitions |
| `--md-sys-motion-easing-standard-accelerate` | `cubic-bezier(0.3, 0, 1, 1)` | Exits |
| `--md-sys-motion-easing-standard-decelerate` | `cubic-bezier(0, 0, 0, 1)` | Entrances |
| `--md-sys-motion-easing-emphasized` | `cubic-bezier(0.2, 0, 0, 1)` | Expressive transitions |
| `--md-sys-motion-easing-emphasized-accelerate` | `cubic-bezier(0.3, 0, 0.8, 0.15)` | Expressive exits |
| `--md-sys-motion-easing-emphasized-decelerate` | `cubic-bezier(0.05, 0.7, 0.1, 1)` | Expressive entrances |
| `--md-sys-motion-easing-linear` | `linear` | Progress, indeterminate loops |

> `--md-sys-motion-easing-emphasized` deliberately equals the standard curve:
> the full M3 emphasized curve is a two-phase path interpolator that CSS cannot
> express. The accelerate and decelerate halves *are* expressible, and are the
> ones to reach for.

MD3 Expressive also ships spring pairs, each an easing plus its natural
duration: `--md-sys-motion-spring-{spatial,effects}-{fast,default,slow}-easing`
and `…-duration`. Spatial = position/size/shape; effects = colour/opacity.

#### State-layer opacities

| Token | Value |
|---|---|
| `--md-sys-state-hover-state-layer-opacity` | `0.08` |
| `--md-sys-state-focus-state-layer-opacity` | `0.12` |
| `--md-sys-state-pressed-state-layer-opacity` | `0.12` |
| `--md-sys-state-dragged-state-layer-opacity` | `0.16` |
| `--md-sys-state-disabled-container-opacity` | `0.12` |
| `--md-sys-state-disabled-content-opacity` | `0.38` |

#### Typography, spacing and layering

- **Typescale** — `--md-sys-typescale-<role>-<size>-*` for
  `display`/`headline`/`title`/`label`/`body` × `large`/`medium`/`small`. Each
  role exposes `-font-family`, `-font-size`, `-line-height`, `-font-weight`,
  `-letter-spacing`, plus a `-font` shorthand
  (e.g. `--md-sys-typescale-headline-medium-font: 400 28px/36px Roboto, sans-serif`).
  Use the shorthand on the `font` property for headings you write yourself.
- **Spacing** — a 4px scale that tightens with density:
  `--md-sys-spacing-inset-{xs,sm,md,lg,xl}` (internal padding, 4/8/12/16/24px)
  and `--md-sys-spacing-gap-{xs,sm,md,lg}` (between siblings, 4/8/12/16px), plus
  `--md-sys-spacing-row-height` (`56px` at density 0).
- **Layering** — `--md-sys-z-index-app-bar` (100), `-navigation` (200),
  `-bottom-sheet` (300), `-popup` (1000), `-dialog-scrim` (1001), `-tooltip`
  (1500), `-snackbar` (2000). Use these instead of inventing z-indexes, or your
  overlay will land under a menu.

### 4.2 Theming and rebranding

To rebrand, redefine the role variables **after** the tokens stylesheet — both
themes, or dark mode inherits your light brand colour:

```css
:root {
  --md-sys-color-primary: #00629E;
  --md-sys-color-on-primary: #FFFFFF;
  --md-sys-color-primary-container: #CFE5FF;
  --md-sys-color-on-primary-container: #001D33;
}
[data-theme="dark"] {
  --md-sys-color-primary: #9BCBFF;
  --md-sys-color-on-primary: #003354;
  --md-sys-color-primary-container: #004A78;
  --md-sys-color-on-primary-container: #CFE5FF;
}
```

Per-component knobs are `--md-<component>-*` custom properties, listed in each
manual's Theming section (`--md-button-container-color`,
`--md-card-container-shape`, …). Prefer those over `::part()`, and prefer
`::part()` over reaching into shadow internals, which are unstable and will
break on any release.

**Overriding a colour role forfeits the library's contrast testing.** Re-verify
AA (4.5:1 text, 3:1 borders and dividers) after any palette change. The Theme
Generator at <https://awc-ui.dev/theme-generator> takes a seed colour, emits both
palettes as `--md-sys-color-*` overrides, and runs the WCAG checks live — use it
rather than hand-picking container and `on-` pairs.

### 4.3 Density

`data-density` steps `-1 → -4`; each rung trims ~4px of padding and touch
target, driving `--md-sys-density-scale` and the spacing tokens. `-4` is the
floor. Set it globally, override locally with the `density` prop. Do not go
below `-2` on touch-primary surfaces — you will break the 48px target.

Two details that bite:

- **`density="0"` does nothing** — see §2 for why, and for the
  `--md-sys-density-scale: 0` escape hatch.
- **`md-table` accepts two vocabularies**: the semantic `compact` / `standard` /
  `comfortable` (row heights 36 / 52 / 60px) *and* the numeric rungs. They
  compose — `density="compact"` inside a `data-density="-2"` region condenses
  further.

### 4.4 Dark mode

Set `data-theme="dark"` on `<html>` (or any ancestor — the nearest wins). The
tokens swap automatically and inherit across every shadow boundary; no
per-component code. To mirror the OS, read `prefers-color-scheme` and write the
attribute **before first paint**, or the page flashes light.

### 4.5 RTL

Layout is written with CSS logical properties, so it flips with `dir="rtl"`
without extra work. Two things still need you:

- **Directional glyphs do not flip.** Material Symbols are not auto-mirrored.
  `md-button` has a `mirror-icon` prop that mirrors its own leading/trailing
  glyph — set it on buttons using arrows, chevrons, `send`, `reply`, and leave
  it off for `add`, `search`, `favorite`. **`mirror-icon` exists only on
  `md-button`.** Everywhere else — `md-icon-button icon="…"`, `md-list-item`
  leading/trailing icons, `md-app-bar leading-icon` — swap the glyph name
  yourself (`arrow_back` ↔ `arrow_forward`).
- **Icon props you supply yourself** — e.g. `md-transfer-list`'s
  `move-right-icon` / `move-left-icon` / `move-all-*-icon` — must be swapped by
  you when the direction flips.

Never write physical CSS (`margin-left`, `padding-right`, `left`) around these
components. Use `margin-inline-start`, `padding-inline-end`, `inset-inline-end`.

### 4.6 Internationalization

Components are **i18n-engine-agnostic by design**. Every user-visible string is
either slotted content or a prop. Localize in the consumer layer: resolve your
dictionary to plain strings, then pass them in. Do not add a translation engine
inside a component. `locale` props exist only where a component computes an
`Intl`-formatted value itself: `md-date-picker`, `md-number-field`,
`md-meter`, `md-dialog`, and the charts (`md-bar-chart`, `md-line-chart`,
`md-area-chart`, `md-pie-chart`). `md-time-picker` has no
`locale` prop — it takes `format="12h" | "24h"` instead.

Templated strings keep their placeholder tokens when translated — translate
around the braces, don't remove them:

```html
<md-transfer-list count-template="{checked} / {total} ausgewählt"></md-transfer-list>
```

Other templated props: `md-autocomplete status-template`, `md-otp-field
cell-label-template`. Default prop values are English (`Search`, `Dismiss`,
`No results`, `Move selected to target`, …) — every one of them needs
translating in a localized app.

### 4.7 Forms and validation

Fourteen components are form-associated via `ElementInternals`, so they
participate in `FormData` and constraint validation like native controls:

`md-text-field` · `md-number-field` · `md-otp-field` · `md-select` ·
`md-multi-select` · `md-autocomplete` · `md-checkbox` · `md-radio` ·
`md-switch` · `md-slider` · `md-rating` · `md-date-picker` · `md-time-picker` ·
`md-button`

Rules:

- Use a real `<form>`. `md-button type="submit"` calls `form.requestSubmit()`
  (not `submit()`), so the `submit` event fires **and** built-in constraint
  validation runs. `required` genuinely blocks submit.
  `md-button type="reset"` calls `form.reset()`.
- Give every control a `name`, or it will not appear in `FormData`.
- Do **not** add hidden `<input>`s to mirror values — that's the old pattern and
  it double-submits.
- Validity changes are announced on a `mdValidityChange` event on the control.
  Error presentation is `error` + `error-text` on the field.
- Boolean state props differ by control — `md-checkbox` uses `checked`,
  `md-switch` uses **`selected`**, `md-select-option` uses `selected`. Check the
  manual; guessing `checked` on a switch silently does nothing.

---

## §5 — Component decision matrix

Route by **need**, not by name. If the need isn't listed, find the closest row
and read that component's `When NOT to use`.

### 5.1 Actions

| Need | Use | Don't use |
|---|---|---|
| Discrete labelled action | `md-button` | `md-chip`, raw `<button>` |
| Icon-only action | `md-icon-button` | `md-button` with no label |
| A destructive action | `md-button` + `md-dialog` to confirm | an unconfirmed `filled` button |
| The single most prominent screen action (mobile) | `md-fab` | a second `filled` `md-button` |
| One prominent action that expands to several | `md-fab-menu` + `md-fab-menu-item` | a stack of FABs |
| Primary action + variants of it | `md-split-button` | button + separate menu |
| 2–5 related actions as one unit | `md-button-group` | loose adjacent buttons |
| Mutually exclusive view/mode switch | `md-segmented-button-set` + `md-segmented-button` | radio buttons, tabs |
| Overflow / contextual actions | `md-menu` + `md-menu-item` | a row of text buttons |

### 5.2 Text input

| Need | Use | Don't use |
|---|---|---|
| Any single-line or multi-line text entry | `md-text-field` | raw `<input>` |
| A number with steppers and locale formatting | `md-number-field` | `md-text-field type="number"` |
| A one-time code / PIN | `md-otp-field` | a row of text fields |
| Text entry with suggestions | `md-autocomplete` | `md-select` |
| Site/app-wide search with a results surface | `md-search` | `md-text-field` with an icon |

### 5.3 Selection

| Need | Use | Don't use |
|---|---|---|
| One of many, from a list | `md-select` + `md-select-option` | radio group over ~7 options |
| One of few (2–5), all visible | `md-radio` | `md-select` |
| Several of many | `md-multi-select` | many `md-checkbox`es |
| Several of few, all visible | `md-checkbox` | `md-multi-select` |
| Instant on/off setting | `md-switch` | `md-checkbox` |
| A value in a numeric range | `md-slider` | `md-text-field type=number` |
| Subjective score | `md-rating` | slider |
| A color | `md-color-picker` | `<input type=color>` |
| Assign a subset from a bounded pool, side by side | `md-transfer-list` | two lists + buttons |
| Filter / attribute / removable entry | `md-chip` | small buttons |
| A date | `md-date-picker` | three selects |
| A time | `md-time-picker` | text field |

### 5.4 Navigation

| Need | Use | Don't use |
|---|---|---|
| Top-level destinations, mobile | `md-navigation-bar` + `md-navigation-tab` | tabs |
| Top-level destinations, desktop | `md-navigation-rail` + `md-navigation-rail-tab` | bottom bar |
| App header: title, actions, search | `md-app-bar` | a custom `<header>` |
| A dense action strip | `md-toolbar` | app bar |
| Sibling views **within** one screen | `md-tabs` + `md-tab` + `md-tab-panels` + `md-tab-panel` | navigation bar |
| Hierarchy / where-am-I | `md-breadcrumbs` + `md-breadcrumb-item` | text links |
| A linear multi-step flow | `md-stepper` + `md-step` | tabs |
| Contextual popup actions | `md-menu` + `md-menu-item`, `md-menu-item-group`, `md-sub-menu-item` | dialog |
| Hierarchical command menu (File → Export → PDF) | `md-menu` + `md-sub-menu-item` | nested dialogs |

### 5.5 Containment and feedback

| Need | Use | Don't use |
|---|---|---|
| Group related content | `md-card` | a bare `<div>` with a border |
| Blocking decision or focused task | `md-dialog` | a new page |
| Critical error the user must acknowledge | `md-dialog` | `md-snackbar` |
| Supplementary content from the bottom (mobile) | `md-bottom-sheet` | dialog |
| Supplementary content from the side (desktop) | `md-side-sheet` | dialog |
| Brief confirmation of an action, optionally undoable | `md-snackbar` | dialog, alert |
| Explain a control on hover/focus | `md-tooltip` | a dialog or inline hint |
| Progressive disclosure of sections | `md-accordion` + `md-accordion-item` | tabs |
| Visual separation | `md-divider` | a styled `<hr>` |
| A vertical set of records | `md-list` + `md-list-item` | a table |
| Determinate/indeterminate progress | `md-progress-indicator` | spinner GIF |
| Brand-consistent page/content loading | `md-loading-indicator` | custom spinner |
| Content-shaped loading placeholder | `md-skeleton` | a spinner over the whole page |
| Count or status on an element | `md-badge` | superscript text |
| Compact status dot | `md-status-dot` | a colored emoji |
| Read-only value within a known range (quota, battery) | `md-meter` | `md-progress-indicator` |
| A person or entity image/initials | `md-avatar` | a raw `<img>` |
| Touch feedback inside a custom control | `md-ripple` | custom CSS animation |

### 5.6 Data

| Need | Use | Don't use |
|---|---|---|
| Any table | `md-table-container` wrapping `md-table`, with `-head`/`-body`/`-row`/`-cell`/`-foot` inside the table and `-toolbar`/`-pagination` beside it in the container (§7.1) | a native `<table>` |
| Sorting, selection, paging on that table | the same parts — `md-table` carries the STATE (`sort-by`, `sort-order`, `selection`, `row-offset`, `row-count`, `loading`) and emits events; you own the data and do the actual sorting/paging | expecting it to sort an array for you |
| Hierarchy / reporting lines | `md-organization-chart` | nested lists |
| Compare categories | `md-bar-chart` | pie chart |
| Trend over time | `md-line-chart` | bar chart |
| Trend with cumulative volume | `md-area-chart` | line chart |
| Parts of a whole (≤ ~6 slices) | `md-pie-chart` | bar chart |
| Inline micro-trend in a cell or card | `md-sparkline` | a full chart |

> **There is no data-driven table component.** `md-table` is composable: you
> render the rows. It tracks and announces sort, selection and pagination state
> and emits events when the user changes them, but the sorting, filtering and
> slicing of your data is yours to perform. Render rows from your own array in
> response to those events.

### 5.7 Choosing the variant

The matrix picks the component; this picks its shape. Every value below is a
real enum member — anything not listed here is not a valid value.

| Component | Prop | Values, and when |
|---|---|---|
| `md-button` | `variant` | `filled` the one primary action · `tonal` a strong secondary · `outlined` secondary, and the safe choice for a destructive action behind a confirm · `text` low emphasis, dialog Cancel · `elevated` when it sits on a busy or coloured background (default `filled`) |
| `md-button` / `md-icon-button` / `md-button-group` | `size` | `xs` `sm` `md` `lg` `xl` — default `sm`; go `md`/`lg` for consumer CTAs, `xs`/`sm` for dense admin UI |
| `md-icon-button` | `variant` | `standard` (default) · `filled` · `tonal` · `outlined` |
| `md-button-group` | `variant` | `standard` spaced · `connected` fused into one bar |
| `md-fab` | `size` | `standard` (default) · `medium` · `large` |
| `md-fab` | `variant` | `primary-container` (default) · `secondary-container` · `tertiary-container` · `surface` · `primary` · `secondary` · `tertiary` |
| `md-card` | `variant` | `filled` for a group of comparable items · `elevated` (default) for a hero or featured item · `outlined` for a settings or form section |
| `md-text-field` / `md-select` | `variant` | `outlined` for forms on a surface · `filled` for dense or tinted layouts (`md-text-field` defaults to `filled`, `md-select` to `outlined`) — pick one and use it for every field on the screen |
| `md-text-field` | `type` | any native input type: `password`, `email`, `tel`, `url`, `search`, … (default `text`) |
| `md-text-field` | `multiline` | `"auto-grow"` grows with content · `"fixed"` with `rows` for a fixed comment box · `false` (default) single line |
| `md-search` | `variant` | `contained` (default) · `divided` |
| `md-app-bar` | `variant` | `small` (default) · `medium` · `large` for a prominent headline · `search` for a bar that hosts a field |
| `md-chip` | `variant` | `assist` (default) · `filter` for toggleable facets · `input` for user-entered removable values · `suggestion` |
| `md-chip` | `appearance` | `outlined` (default) · `filled` · `elevated` |
| `md-badge` | `variant` | `small` a bare dot · `large` (default) a count |
| `md-tooltip` | `variant` | `plain` (default) a short label on an icon control · `rich` an explanatory popover that may hold a link or action |
| `md-divider` | — | **no `variant`** — use the booleans `inset`, `inset-start`, `inset-end` |
| `md-side-sheet` | `variant` | `standard` coexists with page content · `modal` overlays with a scrim |
| `md-bottom-sheet` | `variant` | `standard` (default) · `detached` floating above the edge |
| `md-dialog` | `fullscreen` | boolean — a full-screen dialog for a long mobile task |
| `md-date-picker` | `variant` | `modal-input` (default) calendar plus a typed field · `modal` calendar only · `docked` inline, anchored to the field |
| `md-time-picker` | `variant` | `dial` · `input` (default) |
| `md-progress-indicator` | `variant` | `linear` (default) · `circular`; add `indeterminate` when the total is unknown |
| `md-list-item` | `lines` | `1` · `2` · `3` — must match how much supporting text you pass |
| `md-navigation-bar` | — | 3–5 destinations. Fewer than three: use `md-tabs`. More than five: a rail or a menu |
| `md-navigation-rail` | — | 3–7 destinations; cap the overflow with `max-visible` |

### 5.8 Not in the library

Rich text editor · file upload/dropzone · calendar/scheduler view · map ·
toast stack manager (use `md-snackbar` and manage the queue yourself) ·
data grid with virtualized columns. If the user needs one, say so plainly and
integrate a third-party component styled with the MD3 tokens.

**There is no `md-grid`, no `md-data-table`, no `md-layout`, no `md-icon`.**
If a tag is not listed in §6, it does not exist — do not emit it.

---

## §6 — Component inventory

**Every component has exactly one manual, and it is the readme.md in its own
source folder:**

```
./packages/core/src/components/<tag>/readme.md
```

e.g. [`md-select`](./packages/core/src/components/md-select/readme.md),
[`md-button`](./packages/core/src/components/md-button/readme.md).

**Load that file before you use the component.** There is no second, shorter
summary to rely on — this readme is the single source, so anything you do not
read there, you do not know. Each one carries When-to-use, a Do/Don't table
from M3, copy-paste patterns, an anti-patterns table, and the theming surface.

Work through them one at a time: pick the component from the decision matrix
(§5), load its readme, write that component's markup, then move to the next.

There are 81 manuals for 57 components: 24 of them document sub-components that
are only valid inside a parent (a table cell, a tab panel, a select option).
Every sub-component manual names its parent in the first line, and §7 below
summarises the nesting.

`status` in each manual's `llm:meta` block is one of:

- **`md3-mapped`** — has a Material Design 3 guidelines page; the Do/Don't is
  sourced from it.
- **`custom`** — an addition to MD3; guidance is derived house rules.
- **`sub-component`** — only valid inside a specific parent.

| Category | Components |
|---|---|
| Actions | `md-button` `md-icon-button` `md-fab` `md-fab-menu` `md-fab-menu-item` `md-split-button` `md-button-group` `md-segmented-button` `md-segmented-button-set` |
| Text input | `md-text-field` `md-number-field` `md-otp-field` `md-autocomplete` `md-search` |
| Selection | `md-select` `md-select-option` `md-multi-select` `md-checkbox` `md-radio` `md-switch` `md-slider` `md-rating` `md-color-picker` `md-transfer-list` `md-chip` |
| Pickers | `md-date-picker` `md-time-picker` |
| Navigation | `md-app-bar` `md-toolbar` `md-navigation-bar` `md-navigation-tab` `md-navigation-rail` `md-navigation-rail-tab` `md-tabs` `md-tab` `md-tab-panels` `md-tab-panel` `md-breadcrumbs` `md-breadcrumb-item` `md-menu` `md-menu-item` `md-menu-item-group` `md-sub-menu-item` `md-stepper` `md-step` |
| Containment | `md-card` `md-dialog` `md-bottom-sheet` `md-side-sheet` `md-snackbar` `md-tooltip` `md-accordion` `md-accordion-item` `md-divider` `md-list` `md-list-item` |
| Data | `md-table` `md-table-container` `md-table-head` `md-table-body` `md-table-row` `md-table-cell` `md-table-foot` `md-table-toolbar` `md-table-pagination` `md-table-sort-label` `md-table-expand-toggle` `md-organization-chart` |
| Charts | `md-bar-chart` `md-line-chart` `md-area-chart` `md-pie-chart` `md-sparkline` |
| Status & feedback | `md-progress-indicator` `md-loading-indicator` `md-skeleton` `md-badge` `md-status-dot` `md-meter` `md-avatar` `md-ripple` |

---

## §7 — Composition rules

### 7.1 What nests inside what

A sub-component is only valid inside its parent. Putting one anywhere else
produces an unstyled, unregistered-looking element with no keyboard behaviour,
because the parent is what wires roving tabindex, ARIA ids and selection.

| Parent | Children it manages |
|---|---|
| `md-button-group` | `md-button`, `md-icon-button` |
| `md-segmented-button-set` | `md-segmented-button` |
| `md-fab-menu` | `md-fab-menu-item` (anchored to an `md-fab` via `anchor="<id>"`) |
| `md-menu` | `md-menu-item`, `md-sub-menu-item`, `md-menu-item-group` — **not** `md-divider`; separate rows with `md-menu-item`'s own `divider` (or `gap`) prop |
| `md-menu-item-group` | `md-menu-item` |
| `md-sub-menu-item` | a nested `md-menu` in `slot="submenu"` — the items go in *that* menu. There is no default slot, so anything else you nest renders nothing |
| `md-select`, `md-multi-select`, `md-autocomplete` | `md-select-option` |
| `md-list` | `md-list-item`, `md-divider` |
| `md-tabs` | `md-tab` |
| `md-tab-panels` | `md-tab-panel` (one per tab, in tab order) |
| `md-navigation-bar` | `md-navigation-tab` |
| `md-navigation-rail` | `md-navigation-rail-tab`, plus an `md-fab` in `slot="fab"` |
| `md-accordion` | `md-accordion-item` |
| `md-stepper` | `md-step` (horizontal steppers also take `slot="content"`) |
| `md-breadcrumbs` | `md-breadcrumb-item` |
| `md-table-container` | `md-table` — **it wraps the table, not the other way round** — plus `md-table-toolbar` in `slot="top"` and `md-table-pagination` in `slot="bottom"`, which sit outside the scroll region |
| `md-table` | `md-table-head`, `md-table-body`, `md-table-foot` (and bare `md-table-row`). It does **not** accept the container, toolbar or pagination |
| `md-table-head` / `-body` / `-foot` | `md-table-row` |
| `md-table-row` | `md-table-cell`, plus `md-table-expand-toggle` for an expandable row |
| `md-table-cell` | `md-table-sort-label` in a header cell |
| `md-tooltip` | **its trigger** — the tooltip wraps the element it describes in its default slot |
| `md-dialog` | body content in the default slot; `md-button` in `slot="actions"` |
| `md-bottom-sheet` / `md-side-sheet` | content in the default slot; `md-button` in `slot="actions"`; headline in `slot="headline"` |
| `md-app-bar` | `md-icon-button` in `slot="leading"` and `slot="trailing"`; `md-menu` for overflow; a field in `slot="search"` on `variant="search"` |
| `md-toolbar` | `md-icon-button`, `md-button`, `md-button-group`; an `md-fab` in `slot="fab"`; `slot="leading"` / `slot="trailing"` for the end clusters |

### 7.2 Pairs that belong together

These don't nest — they sit next to each other in a working flow. Reaching for
one usually means you want the other.

| This | Goes with | Why |
|---|---|---|
| `md-icon-button` | `md-tooltip` | The tooltip supplies the visible meaning the icon lacks |
| `md-button` (`soft-disabled`) | `md-tooltip` | Explains *why* the action is unavailable |
| `md-fab` | `md-fab-menu` | The FAB is the menu's anchor |
| `md-split-button` | `md-menu` | The trailing half opens it |
| `md-card` | `md-button`, `md-icon-button`, `md-divider` | Footer actions, corner action, internal sections |
| `md-list-item` | `md-checkbox`, `md-switch`, `md-icon-button` | Trailing controls in a selectable or settings row |
| `md-bottom-sheet` / `md-side-sheet` | `md-list` | Action menus and filter panels inside the sheet |
| `md-search` | `md-list`, `md-avatar`, `md-icon-button` | Results in the panel; account and voice/filter affordances in the trailing slot |
| `md-date-picker` | `md-time-picker` | Date + time row for booking and scheduling forms |
| `md-time-picker` | `md-button` | The trigger, when `hide-trigger` is set. The picker **is** its own dialog — don't nest it in another one |
| `md-text-field` | `md-button` | Submit / cancel in the form footer |
| `md-multi-select` / `md-autocomplete` | `md-text-field`, `md-menu`, `md-chip` | The field is the trigger (and inherits its variant, density and error state), the menu is the option surface, chips are the selected values |
| `md-autocomplete` | `md-progress-indicator` | The loading row while suggestions are fetched |
| `md-transfer-list` | `md-checkbox`, `md-text-field`, `md-icon-button` | Per-row select, per-side search, mover controls |
| `md-number-field` | `md-text-field` hooks, `md-icon-button` | It *is* an `md-text-field` internally — every `--md-text-field-*` custom property passes through — and its steppers are `md-icon-button`s |
| `md-otp-field` | `md-button` | Verify action (`auto-submit` covers the no-button flow) |
| Any chart | `md-card` | Charts belong on a dashboard tile |
| `md-line-chart` | `md-segmented-button-set` | Period picker (1W / 1M / 1Y) driving the range |
| `md-sparkline` | `md-list-item`, `md-table-cell` | Trend column beside a value |
| `md-skeleton` | `md-card`, `md-list` | Render N placeholders in the real layout while fetching |
| `md-meter` | `md-chip`, `md-card` | Same semantic status colour on both; quota and usage summaries live on a card |
| `md-color-picker` | `md-text-field`, `md-button` | Label and helper text beside it in a form; save / cancel in the surrounding popover or dialog |
| `md-rating` | `md-text-field`, `md-card` | A rating row in a review form; aggregate scores on a review card |
| `md-accordion` | `md-divider` | Optional inner dividers inside long item content |
| `md-stepper` | `md-button` | Next / back / submit adjacent to the stepper |

### 7.3 Nesting that is always wrong

- A dialog opened from inside a dialog. Use `md-stepper` inside **one**
  `md-dialog`.
- A component inside a native interactive element (`<button>`, `<a>`) — it
  nests interactive controls and destroys the accessibility tree. Use the
  component's own `href` / `type` props.
- `md-tabs` used for top-level app navigation. Tabs switch sibling views of the
  same data; destinations are `md-navigation-bar` / `md-navigation-rail`.

---

## §8 — Page recipes

Complete, runnable screens. Each renders as-is once the components are
registered and the token sheet and font links from §3 are loaded — no
placeholder identifiers, no helper functions to write.

### 8.1 Login screen

`<form>` is load-bearing: it is what makes `required` block submit and what
`type="submit"` calls `requestSubmit()` on.

```html
<main style="display: grid; place-items: center; min-block-size: 100dvh; padding: 24px;">
  <form id="login-form" style="inline-size: min(420px, 100%);">
    <md-card variant="elevated" style="padding: 32px; display: flex; flex-direction: column; gap: 20px;">
      <h1 style="margin: 0; font: var(--md-sys-typescale-headline-medium-font);">Sign in</h1>

      <md-text-field
        variant="outlined"
        label="Email"
        type="email"
        name="email"
        autocomplete="username"
        required
      ></md-text-field>

      <md-text-field
        variant="outlined"
        label="Password"
        type="password"
        name="password"
        autocomplete="current-password"
        password-toggle="internal"
        required
      ></md-text-field>

      <md-button variant="filled" type="submit" full-width>Sign in</md-button>
      <md-button variant="text" href="/forgot-password">Forgot password?</md-button>
    </md-card>
  </form>
</main>

<script type="module">
  document.getElementById('login-form').addEventListener('submit', (e) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    console.log(data.get('email'), data.get('password'));
  });
</script>
```

### 8.2 Settings page (mobile)

Top app bar + grouped rows of instant-apply switches. `md-switch` uses
**`selected`**, not `checked`. The rows are `type="text"` (non-interactive), so
the switch is the only control — one tab stop per setting.

```html
<md-app-bar variant="small" headline="Settings">
  <md-icon-button slot="leading" icon="arrow_back" aria-label="Back"></md-icon-button>
</md-app-bar>

<main style="padding: 16px; display: flex; flex-direction: column; gap: 16px;">
  <md-card variant="outlined">
    <md-list>
      <md-list-item headline="Notifications" supporting-text="Push, email, in-app" lines="2">
        <md-switch slot="trailing" selected aria-label="Enable notifications" data-setting="notifications"></md-switch>
      </md-list-item>
      <md-divider></md-divider>
      <md-list-item headline="Dark mode" supporting-text="Match system" lines="2">
        <md-switch slot="trailing" aria-label="Enable dark mode" data-setting="dark"></md-switch>
      </md-list-item>
      <md-divider></md-divider>
      <md-list-item headline="Sync over cellular">
        <md-switch slot="trailing" selected aria-label="Sync over cellular" data-setting="cellular"></md-switch>
      </md-list-item>
    </md-list>
  </md-card>
</main>

<md-snackbar id="settings-toast" message="Setting saved"></md-snackbar>

<script type="module">
  const toast = document.getElementById('settings-toast');
  document.querySelectorAll('md-switch[data-setting]').forEach((sw) => {
    sw.addEventListener('mdChange', (e) => {
      if (sw.dataset.setting === 'dark') {
        document.documentElement.setAttribute('data-theme', e.detail.selected ? 'dark' : 'light');
      }
      toast.show();
    });
  });
</script>
```

### 8.3 Settings form (deferred save, with validation)

When settings are saved on submit rather than applied instantly, use a real
form. `required` blocks the submit; every control needs a `name` to reach
`FormData`.

```html
<form id="profile-form" style="max-inline-size: 560px; margin: 24px auto; padding: 0 16px;">
  <md-card variant="outlined" style="padding: 24px; display: flex; flex-direction: column; gap: 20px;">
    <h2 style="margin: 0; font: var(--md-sys-typescale-title-large-font);">Profile</h2>

    <md-text-field
      variant="outlined"
      label="Display name"
      name="displayName"
      required
      supporting-text="Shown on your public profile"
    ></md-text-field>

    <md-select variant="outlined" label="Language" name="language" value="en" required>
      <md-select-option value="en" label="English"></md-select-option>
      <md-select-option value="de" label="Deutsch"></md-select-option>
      <md-select-option value="ar" label="العربية"></md-select-option>
    </md-select>

    <!-- md-checkbox has NO label slot. It is a labelable, form-associated
         element: wrap it in a native <label> (which names it AND forwards
         clicks), or give the host an aria-label. Text between the tags is
         NOT rendered. -->
    <label style="display: inline-flex; align-items: center; gap: 12px; cursor: pointer;">
      <md-checkbox name="newsletter" value="yes" supporting-text="Monthly, no more"></md-checkbox>
      <span>Send me product news</span>
    </label>

    <div style="display: flex; gap: 8px; justify-content: flex-end;">
      <md-button variant="text" type="reset">Reset</md-button>
      <md-button variant="filled" type="submit">Save changes</md-button>
    </div>
  </md-card>
</form>

<md-snackbar id="saved-toast" message="Changes saved" action="Undo"></md-snackbar>

<script type="module">
  const form = document.getElementById('profile-form');
  const toast = document.getElementById('saved-toast');

  form.addEventListener('submit', (e) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(form));
    console.log(data); // { displayName, language, newsletter? }
    toast.show();
  });

  toast.addEventListener('mdAction', () => form.reset());
</script>
```

### 8.4 Dashboard with a FAB

```html
<md-app-bar variant="medium" headline="Inbox">
  <md-icon-button slot="trailing" icon="search" aria-label="Search"></md-icon-button>
  <md-icon-button slot="trailing" icon="filter_list" aria-label="Filter"></md-icon-button>
</md-app-bar>

<main style="padding: 16px; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));">
  <md-card variant="filled" interactive style="padding: 16px;">
    <strong>Acme contract</strong>
    <p style="margin: 4px 0 0; color: var(--md-sys-color-on-surface-variant);">Renewal due in 5 days</p>
  </md-card>
  <md-card variant="filled" interactive style="padding: 16px;">
    <strong>Q4 report draft</strong>
    <p style="margin: 4px 0 0; color: var(--md-sys-color-on-surface-variant);">Shared by Alex</p>
  </md-card>
</main>

<md-fab
  icon="add"
  aria-label="New item"
  style="position: fixed; inset-block-end: 16px; inset-inline-end: 16px; z-index: var(--md-sys-z-index-navigation);"
></md-fab>
```

`inset-inline-end` (not `right`) is what keeps the FAB in the correct corner
under `dir="rtl"`.

### 8.5 Destructive confirmation dialog

`md-dialog` traps focus and returns it to the trigger on close — do not add your
own focus management. The error colouring goes through the button's own custom
properties, never a hex.

```html
<md-button id="open-delete" variant="outlined">Delete account</md-button>

<md-dialog id="confirm-delete" headline="Delete account?" icon="warning">
  <p style="margin: 0;">
    This will permanently delete your account and all associated data.
    This action cannot be undone.
  </p>
  <md-button id="cancel-delete" slot="actions" variant="text">Cancel</md-button>
  <md-button
    id="do-delete"
    slot="actions"
    variant="filled"
    style="--md-button-container-color: var(--md-sys-color-error);
           --md-button-label-color: var(--md-sys-color-on-error);"
  >Delete</md-button>
</md-dialog>

<script type="module">
  const dialog = document.getElementById('confirm-delete');
  document.getElementById('open-delete').addEventListener('mdClick', () => dialog.show());
  document.getElementById('cancel-delete').addEventListener('mdClick', () => dialog.close());
  document.getElementById('do-delete').addEventListener('mdClick', () => {
    dialog.close();
    // perform the deletion
  });
</script>
```

If you omit `slot="actions"` entirely, `md-dialog` renders its own Cancel / OK
pair (labels via `cancel-label` / `ok-label`) and closes itself.

### 8.6 Tabbed content page

`md-tab-panels` finds the nearest preceding `md-tabs` (or the one named by
`for`), follows its `mdTabChange`, and wires `aria-controls` /
`aria-labelledby` both ways. **No JavaScript is required**, and `md-tab` has no
`value` or `selected` prop — selection lives on `md-tabs` as `active-tab-index`.

```html
<md-tabs id="profile-tabs" aria-label="Profile sections" active-tab-index="0">
  <md-tab label="Posts"></md-tab>
  <md-tab label="Replies"></md-tab>
  <md-tab label="Likes" badge="3"></md-tab>
</md-tabs>

<md-tab-panels for="profile-tabs">
  <md-tab-panel style="padding: 16px;">
    <p style="margin: 0;">Your posts appear here.</p>
  </md-tab-panel>
  <md-tab-panel style="padding: 16px;">
    <p style="margin: 0;">Your replies appear here.</p>
  </md-tab-panel>
  <md-tab-panel style="padding: 16px;">
    <p style="margin: 0;">Posts you liked appear here.</p>
  </md-tab-panel>
</md-tab-panels>
```

To react to the switch (lazy-loading a panel, for example), listen on the tabs:

```js
document.getElementById('profile-tabs')
  .addEventListener('mdTabChange', (e) => console.log(e.detail.index, e.detail.previousIndex));
```

### 8.7 The full recipe library

Twenty-two more complete screens live in the documentation, each with a live
demo, the full markup, framework tabs, and production notes. When the user's
request matches one of these, read that page (append the slug to
`https://awc-ui.dev/recipes/`) instead of composing from scratch:

| Recipe slug | Screen |
|---|---|
| `two-factor-verification` | OTP entry, resend countdown, backup-codes dialog |
| `kpi-overview` | Stat tiles with sparklines + charts under a range switcher |
| `data-grid-console` | Bulk-action data table: filters, selection, undo |
| `server-fleet-status` | Table of hosts with meters, sparklines, status dots |
| `release-health-drilldown` | Table row → side-sheet master-detail with charts |
| `csv-import-wizard` | Stepper: upload, map columns, validate, import |
| `role-permission-assignment` | Transfer-list permissions with confirm dialog |
| `org-chart-explorer` | Searchable org chart with side-sheet profiles |
| `moderation-queue` | Verdict controls, confidence meter, card queue |
| `checkout-wizard` | Three-step commerce checkout with validation |
| `appointment-booking` | Date + time pickers inside a stepper flow |
| `survey-nps` | Paged survey using every input control |
| `notification-preferences` | Accordion × switch matrix, quiet hours |
| `app-shell` | Responsive rail/bar chrome, SSR-ready, zero CLS |
| `inbox-shell` | Three-zone list + reading-pane layout |
| `mobile-filter-sheet` | Bottom-sheet filters with applied-chip row |
| `product-reviews` | Rating input + per-star meter breakdown |
| `order-tracking` | Shipment timeline stepper with progress meter |
| `portfolio-markets` | Finance charts + holdings table + trade ticket |
| `pricing-page` | Plan cards, billing toggle, comparison table |
| `async-feedback-patterns` | Skeleton/progress/snackbar choreography rules |
| `charts-gallery` | Every chart component, live-retheming palette |

---

## §9 — Universal do's and don'ts

Apply to every component. Component-specific rules live in each manual.

### 9.1 API and styling

| ✅ Do | ❌ Don't |
|---|---|
| Use the custom element directly — it *is* the control, with its own `href` / `type` props | Wrap it in a native `<button>` / `<a>` (§7.3) |
| Set arrays and objects as **JS properties** (`el.items = [...]`) | Pass them as HTML attributes — they won't parse |
| Theme via `--md-<component>-*` properties, then `::part()` | Reach into shadow internals or override `.md-*` classes |
| Change appearance through design tokens (§4.1) | Hardcode hex colors, px spacing, shadows, or font stacks |
| Use `--md-sys-z-index-*` for your own overlays | Invent z-indexes that land under a menu |
| Use logical CSS (`margin-inline-start`, `inset-inline-end`) | Use `margin-left`, `right`, `padding-right` |
| Set `data-density` / `data-theme` / `dir` once, globally | Set them per component unless you mean a local exception |
| Listen to the component's `md*` events | Rely on native `click` — it fires even when the component's `disabled` / `loading` guard suppressed the action |
| Check the manual for the state prop name | Assume `checked`; `md-switch` uses `selected` |
| Render the element, then call its `@Method` | Call `.show()` on an element not yet in the DOM |

Toggling components flip their own state on activation and *then* emit. On
`md-button`, `mdClick` is cancelable — `preventDefault()` on it vetoes the
toggle and any navigation. `mdChange` fires after the flip and is not
cancelable. If a controlled parent rejects a change it must revert the child
explicitly.

`md-button` has a **toggle mode**: set `toggle` and the button flips its own
`selected` on each activation and exposes `aria-pressed`. `selected` is the
state, not the switch — set it for the initial pressed state and read it back
from `mdClick`'s `detail.selected`. Setting `toggle` and then styling
"pressed" yourself, or setting `selected` without `toggle` (which emits no
`aria-pressed` at all), both produce a control that lies to assistive tech.

### 9.2 Content and hierarchy

| ✅ Do | ❌ Don't |
|---|---|
| Use sentence case for all labels | Uppercase or title-case UI text |
| Keep one high-emphasis action per screen region | Compete `filled` buttons against each other |
| Use `soft-disabled` + `md-tooltip` for contextually-unavailable actions | Use `disabled` and remove it from tab order with no explanation |
| Localize every text prop and `aria-label` | Leave default English prop values in a translated app |

**`disabled` vs `soft-disabled`.** Both render the disabled appearance, but
`disabled` also removes the control from the tab order, so a keyboard or screen
reader user can never find out *why* it is off. `soft-disabled` keeps the
control focusable and announced while still blocking activation — which is what
lets an `md-tooltip` on it explain the gate. Use it whenever the reason is
informative rather than obvious.

Nesting rules — dialog inside a dialog, a component inside a native
`<button>` / `<a>`, tabs used as app navigation — live in §7.3.

### 9.3 Accessibility contract

The library ships WCAG 2.1 AA keyboard and ARIA wiring by default. These are the
rules you must not break:

1. **Every icon-only control gets an accessible name.** `md-icon-button`,
   `md-fab`, and icon-only `md-tab` are nameless without `aria-label` (or
   `aria-labelledby`). `md-fab` will also accept `label`, and warns in the
   console when it has neither.
2. **Every form field gets a `label` prop.** `placeholder` is not a label — it
   disappears on type.
3. **Don't break the tab order.** Every interactive control must be reachable by
   Tab and operable by Enter/Space. If you add `tabindex="-1"` to anything,
   have a reason.
4. **Don't write your own focus trap.** `md-dialog`, `md-bottom-sheet` and
   `md-side-sheet` trap focus while open and restore it to the trigger on
   close — including across shadow boundaries. Adding your own fights theirs.
5. **Don't wrap components in your own live regions.** `md-snackbar` announces
   itself (`politeness="polite"` by default, `"assertive"` when the message must
   interrupt). For an error that blocks the task, use `md-dialog`.
6. **Don't disable motion yourself.** Components honour
   `prefers-reduced-motion: reduce` internally.
7. **Directional icons do not auto-flip in RTL** — see §4.5.
8. **Re-verify contrast after overriding any colour role** — the library's AA
   testing covers the shipped palette only. The Theme Generator at
   <https://awc-ui.dev/theme-generator> runs the WCAG checks live (§4.2).

---

## §10 — Before you ship

1. **Every icon-only control has an accessible name.**
2. **Keyboard-only pass**: reach and operate every control; focus is always
   visible; no traps in dialogs/menus/sheets; focus returns to the trigger.
3. **Theme pass**: light *and* dark, if both are supported — and the attribute
   is set before first paint.
4. **RTL pass**, if an RTL locale ships — check directional icons specifically,
   and grep your own CSS for physical properties.
5. **Density pass** at the configured rung — no clipped labels, touch targets
   still adequate. Remember `density="0"` is inert; use
   `--md-sys-density-scale: 0` to opt a subtree out.
6. **Forms**: every control has a `name`; `required` blocks submit; `FormData`
   contains every field; reset works.
7. **No hardcoded strings** left in markup if the app is localized — including
   default prop values like `Search`, `Dismiss`, `No results`.
8. **No hardcoded design values** — grep the diff for hex colours, `px` radii,
   `box-shadow`, and `z-index` literals.
9. **Contrast** re-verified against AA if colour roles were overridden.
10. **SSR**: if server-rendered, confirm no hydration mismatch and that content
    is present with JS disabled.
11. **No shadow-internal CSS** — only tokens, custom properties, and `::part()`.
12. **Every `md-*` tag you emitted appears in §6.** If it doesn't, it doesn't
    exist.