Skip to content

cv-menu

Menu panel that displays a list of actionable items, supporting checkable items (checkbox/radio), submenus, groups, and typeahead navigation.

Headless: createMenu

Usage

View source
html
<div class="menu-demo-shell" data-demo="menu" data-live-demo-height="760">
  <header class="menu-demo-hero">
    <div class="menu-demo-copy">
      <span class="menu-demo-kicker">Menu contract</span>
      <h3>Operate a dense action surface without losing keyboard state or checkable context.</h3>
      <p>
        The menu keeps selection, active item, checkbox groups, radio groups, disabled actions, and submenu
        affordances in the headless model while the UIKit layer owns only rendering.
      </p>
    </div>

    <dl class="menu-demo-metrics" aria-label="Menu contract coverage">
      <div>
        <dt>Pattern</dt>
        <dd>APG menu</dd>
      </div>
      <div>
        <dt>Groups</dt>
        <dd>Checkbox + radio</dd>
      </div>
      <div>
        <dt>Events</dt>
        <dd><code>cv-input</code> + <code>cv-change</code></dd>
      </div>
    </dl>
  </header>

  <section class="menu-demo-board" aria-label="Vault action menu demo">
    <div class="menu-demo-panel menu-demo-panel--primary">
      <div class="menu-demo-section-header">
        <span class="menu-demo-kicker">Persistent command menu</span>
        <h4>Pick actions, toggle visible panels, and change the command target in one open menu.</h4>
      </div>

      <cv-menu
        class="menu-demo-menu"
        data-menu-demo-primary
        open
        close-on-select="false"
        aria-label="Vault command actions"
        value="inspect"
      >
        <cv-menu-item value="inspect">
          <span slot="prefix" class="menu-demo-glyph" aria-hidden="true">i</span>
          Inspect route
          <span slot="suffix">I</span>
        </cv-menu-item>
        <cv-menu-item value="copy-proof">
          <span slot="prefix" class="menu-demo-glyph" aria-hidden="true">#</span>
          Copy proof label
          <span slot="suffix">C</span>
        </cv-menu-item>
        <cv-menu-item value="reveal-risk" disabled>
          <span slot="prefix" class="menu-demo-glyph" aria-hidden="true">!</span>
          Reveal locked route
          <span slot="suffix">policy</span>
        </cv-menu-item>

        <cv-menu-group type="checkbox" label="Visible panels">
          <cv-menu-item value="event-log" checked>Event log</cv-menu-item>
          <cv-menu-item value="risk-badges">Risk badges</cv-menu-item>
          <cv-menu-item value="trace-overlay" checked>Trace overlay</cv-menu-item>
        </cv-menu-group>

        <cv-menu-group type="radio" label="Command target">
          <cv-menu-item value="visible-vault" checked>Visible vault</cv-menu-item>
          <cv-menu-item value="hidden-layer">Hidden layer</cv-menu-item>
          <cv-menu-item value="external-export" disabled>External export</cv-menu-item>
        </cv-menu-group>

        <cv-menu-item value="share">
          <span slot="prefix" class="menu-demo-glyph" aria-hidden="true">+</span>
          Share route
          <span slot="suffix">submenu</span>
          <cv-menu slot="submenu" aria-label="Share route options">
            <cv-menu-item value="share-link">Copy link</cv-menu-item>
            <cv-menu-item value="share-report">Create report</cv-menu-item>
          </cv-menu>
        </cv-menu-item>
      </cv-menu>
    </div>

    <aside class="menu-demo-side" aria-label="Live menu state">
      <span class="menu-demo-kicker">Event readout</span>
      <dl class="menu-demo-state">
        <div>
          <dt>Value</dt>
          <dd data-menu-demo-value>inspect</dd>
        </div>
        <div>
          <dt>Active item</dt>
          <dd data-menu-demo-active>none</dd>
        </div>
        <div>
          <dt>Open</dt>
          <dd data-menu-demo-open>true</dd>
        </div>
      </dl>
      <output class="menu-demo-log" data-menu-demo-output>ready -> inspect</output>
    </aside>
  </section>

  <section class="menu-demo-cases" aria-label="Menu state matrix">
    <div class="menu-demo-section-header">
      <span class="menu-demo-kicker">State matrix</span>
      <h4>Reference cases stay visible without scattering five separate menus across the page.</h4>
    </div>

    <div class="menu-demo-case-grid">
      <div class="menu-demo-case">
        <span class="menu-demo-label">Basic actions</span>
        <cv-menu open close-on-select="false" aria-label="Basic actions">
          <cv-menu-item value="cut">Cut</cv-menu-item>
          <cv-menu-item value="copy">Copy</cv-menu-item>
          <cv-menu-item value="paste">Paste</cv-menu-item>
        </cv-menu>
      </div>

      <div class="menu-demo-case">
        <span class="menu-demo-label">Disabled item</span>
        <cv-menu open close-on-select="false" aria-label="Edit actions">
          <cv-menu-item value="undo">Undo</cv-menu-item>
          <cv-menu-item value="redo" disabled>Redo</cv-menu-item>
        </cv-menu>
      </div>

      <div class="menu-demo-case">
        <span class="menu-demo-label">Checkable group</span>
        <cv-menu open close-on-select="false" aria-label="View options">
          <cv-menu-group type="checkbox" label="Panels">
            <cv-menu-item value="toolbar" checked>Toolbar</cv-menu-item>
            <cv-menu-item value="sidebar">Sidebar</cv-menu-item>
            <cv-menu-item value="statusbar" checked>Status Bar</cv-menu-item>
          </cv-menu-group>
        </cv-menu>
      </div>
    </div>
  </section>
</div>

<script type="module">
  document.querySelectorAll('.menu-demo-shell[data-demo="menu"]:not([data-ready])').forEach((shell) => {
    shell.dataset.ready = 'true'

    const value = shell.querySelector('[data-menu-demo-value]')
    const active = shell.querySelector('[data-menu-demo-active]')
    const open = shell.querySelector('[data-menu-demo-open]')
    const output = shell.querySelector('[data-menu-demo-output]')
    const primary = shell.querySelector('[data-menu-demo-primary]')

    const emitState = (type, detail) => {
      const selected = detail.value || 'none'
      if (value) value.textContent = selected
      if (active) active.textContent = detail.activeId || 'none'
      if (open) open.textContent = detail.open ? 'true' : 'false'
      if (output) output.textContent = `${type} -> ${selected} (${detail.open ? 'open' : 'closed'})`
    }

    shell.querySelectorAll('cv-menu').forEach((menu) => {
      menu.addEventListener('cv-input', (event) => emitState(event.type, event.detail))
      menu.addEventListener('cv-change', (event) => emitState(event.type, event.detail))
    })

    if (primary) {
      requestAnimationFrame(() => {
        emitState('ready', {
          value: primary.value || 'inspect',
          activeId: null,
          open: primary.open,
        })
      })
    }
  })
</script>

Anatomy

<cv-menu> (host)
└── <div part="base" role="menu">
    └── <slot>   ← cv-menu-item / cv-menu-group children

Attributes

AttributeTypeDefaultDescription
valueString""Last selected item value
openBooleanfalseWhether the menu panel is visible
close-on-selectBooleantrueClose after item selection; "false" keeps declarative menus open
aria-labelString""Accessible label for the menu

Slots

SlotDescription
(default)cv-menu-item and cv-menu-group children

CSS Parts

PartElementDescription
base<div>Root menu container with role="menu"

CSS Custom Properties

PropertyDefaultDescription
--cv-menu-paddingvar(--cv-space-1, 4px)Padding inside the menu
--cv-menu-gapvar(--cv-space-1, 4px)Gap between menu items
--cv-menu-border-radiusvar(--cv-radius-md, 10px)Border radius of the menu
--cv-menu-backgroundvar(--cv-color-surface-elevated, #1d2432)Background color of the menu
--cv-menu-border-colorvar(--cv-color-border, #2a3245)Border color of the menu
--cv-menu-shadowvar(--cv-shadow-1, 0 2px 8px rgba(0, 0, 0, 0.24))Box shadow of the menu
--cv-menu-max-heightnoneMaximum height of the menu (scrollable when exceeded)
--cv-menu-min-inline-size180pxMinimum inline size of the menu

Visual States

Host selectorDescription
:host([open])Menu panel is visible
:host(:not([open]))Menu panel is hidden

Events

EventDetailDescription
cv-input{value, activeId, open}Fires on any state change (selection, active, open)
cv-change{value, activeId, open}Fires only when the selected value changes

Event detail type:

ts
interface CVMenuEventDetail {
  value: string | null
  activeId: string | null
  open: boolean
}

Reactive State Mapping

cv-menu is a visual adapter over headless createMenu.

UIKit PropertyDirectionHeadless Binding
valueattr -> actionactions.select(value) when value changes
openattr -> actionactions.open() when true; actions.close() when false
close-on-selectattr -> optionpassed as closeOnSelect in createMenu(options)
aria-labelattr -> optionpassed as ariaLabel in createMenu(options)
Headless StateDirectionDOM Reflection
state.isOpen()state -> attr[open] host attribute, menu [hidden]
state.activeId()state -> DOM[data-active] on item elements, focus management
state.selectedId()state -> attr[value] host attribute
state.checkedIds()state -> DOM[aria-checked] on checkbox/radio item elements
state.openSubmenuId()state -> DOMsubmenu container [hidden] state
state.submenuActiveId()state -> DOM[data-active] on submenu child items

Contracts applied to DOM elements:

  • contracts.getMenuProps() -> menu container ([part="base"]): provides id, role, tabindex, aria-label, aria-activedescendant
  • contracts.getItemProps(id) -> each item element: provides id, role, tabindex, aria-disabled, data-active, aria-checked, aria-haspopup, aria-expanded
  • contracts.getGroupProps(groupId) -> group container elements: provides id, role, aria-label
  • contracts.getSubmenuProps(parentItemId) -> submenu containers: provides id, role, tabindex, hidden
  • contracts.getSubmenuItemProps(parentItemId, childId) -> submenu item elements: provides id, role, tabindex, aria-disabled, data-active, aria-checked

UIKit does not own activation, navigation, check toggle, or submenu logic; headless state is the source of truth.

Child Elements

cv-menu-item

Actionable item within a menu. Supports standard, checkbox, and radio types, as well as hosting a submenu.

Anatomy

<cv-menu-item> (host)
└── <div part="base" class="item">
    ├── <span part="checkmark">          ← only for checkbox/radio items
    ├── <span part="prefix">
    │   └── <slot name="prefix">
    ├── <span part="label">
    │   └── <slot>
    ├── <span part="suffix">
    │   └── <slot name="suffix">
    └── <span part="submenu-icon">       ← only when item has submenu

Attributes

AttributeTypeDefaultDescription
valueString""Identifier for the item (used as selection value and typeahead matching)
disabledBooleanfalsePrevents selection and skips during navigation
typeString"normal"Item type: normal | checkbox | radio (inherited from parent cv-menu-group when not explicitly set)
checkedBooleanfalseChecked state for checkbox/radio items
activeBooleanfalseReflects keyboard-active (highlighted) state (managed by parent)
selectedBooleanfalseReflects whether this item is the last selected value (managed by parent)
labelString""Explicit label for typeahead matching (defaults to text content if not set)

Slots

SlotDescription
(default)Item label text
prefixIcon or element before the label
suffixIcon or element after the label (e.g., keyboard shortcut text)
submenuNested cv-menu for submenu content

CSS Parts

PartElementDescription
base<div>Item root wrapper
checkmark<span>Check indicator for checkbox/radio items (rendered only when type is checkbox or radio)
prefix<span>Wrapper around the prefix slot
label<span>Wrapper around the default slot
suffix<span>Wrapper around the suffix slot
submenu-icon<span>Arrow indicator for items with submenu (rendered only when submenu slot is populated)

CSS Custom Properties

PropertyDefaultDescription
--cv-menu-item-padding-inlinevar(--cv-space-3, 12px)Horizontal padding of the item
--cv-menu-item-padding-blockvar(--cv-space-2, 8px)Vertical padding of the item
--cv-menu-item-border-radiusvar(--cv-radius-sm, 6px)Border radius of the item
--cv-menu-item-gapvar(--cv-space-2, 8px)Gap between internal parts (checkmark, prefix, label, suffix, submenu-icon)

Visual States

Host selectorDescription
:host([active])Item has keyboard focus (primary tint at 24%)
:host([selected])Item is the last selected value (primary tint at 32%)
:host([disabled])Item is non-selectable (opacity 0.5)
:host([hidden])Item is hidden when menu is closed
:host([checked])Checkbox/radio item is checked (checkmark visible)
:host([has-submenu])Item hosts a submenu (submenu-icon visible)

ARIA Contract

Item typeroleAdditional attributes
normal (default)menuitemtabindex="-1", aria-disabled (when disabled), data-active
checkboxmenuitemcheckboxtabindex="-1", aria-disabled, data-active, aria-checked
radiomenuitemradiotabindex="-1", aria-disabled, data-active, aria-checked
any with submenuadds to existing rolearia-haspopup="menu", aria-expanded

cv-menu-group

Groups related menu items under a label. Children inherit the type attribute for checkbox/radio behavior.

Anatomy

<cv-menu-group> (host)
├── <div part="label" role="presentation">   ← from label attribute or label slot
└── <div part="base" role="group">
    └── <slot>   ← cv-menu-item children

Attributes

AttributeTypeDefaultDescription
typeString""Checkable type propagated to children: checkbox | radio
labelString""Group accessible name (used as aria-label on the group container)

Slots

SlotDescription
(default)cv-menu-item children
labelCustom group heading content (overrides label attribute)

CSS Parts

PartElementDescription
base<div>Group container with role="group"
label<div>Group label text element

CSS Custom Properties

PropertyDefaultDescription
--cv-menu-group-label-padding-inlinevar(--cv-space-3, 12px)Horizontal padding of the group label
--cv-menu-group-label-font-size0.75emFont size of the group label
--cv-menu-group-gapvar(--cv-space-1, 4px)Gap between items within the group

Visual States

None. The group itself has no interactive visual states.

ARIA Contract

AttributeValue
rolegroup (on [part="base"])
aria-labelgroup label text

cv-menu-button

Button that opens a menu popup. Supports standard and split-button patterns.

Anatomy

<cv-menu-button> (host)
└── <div part="base">
    ├── <button part="trigger">                ← standard mode: single trigger
    │   ├── <span part="prefix">
    │   │   └── <slot name="prefix">
    │   ├── <span part="label">
    │   │   └── <slot>
    │   ├── <span part="suffix">
    │   │   └── <slot name="suffix">
    │   └── <span part="dropdown-icon">
    └── <div part="menu" role="menu">
        └── <slot name="menu">               ← cv-menu-item children

Split-button mode ([split]):

<cv-menu-button split> (host)
└── <div part="base">
    ├── <button part="action">                ← primary action
    │   ├── <span part="prefix">
    │   │   └── <slot name="prefix">
    │   ├── <span part="label">
    │   │   └── <slot>
    │   └── <span part="suffix">
    │       └── <slot name="suffix">
    ├── <button part="dropdown">              ← opens menu
    │   └── <span part="dropdown-icon">
    └── <div part="menu" role="menu">
        └── <slot name="menu">

Attributes

AttributeTypeDefaultDescription
valueString""Last selected menu item value
openBooleanfalseWhether the menu popup is visible
disabledBooleanfalsePrevents all interaction
splitBooleanfalseEnables split-button mode with separate action and dropdown areas
sizeString"medium"Size: small | medium | large
variantString"default"Visual variant: default | primary | danger | ghost
presetStringSemantic preset: icon-overflow
close-on-selectBooleantrueClose the menu after an item is selected
aria-labelString""Accessible label for the trigger/dropdown

Sizes

Size--cv-menu-button-min-height--cv-menu-button-padding-inline--cv-menu-button-padding-block
small30pxvar(--cv-space-2, 8px)var(--cv-space-1, 4px)
medium36pxvar(--cv-space-3, 12px)var(--cv-space-2, 8px)
large42pxvar(--cv-space-4, 16px)var(--cv-space-2, 8px)

Variants

VariantDescription
defaultDefault surface background with border
primaryPrimary-tinted background and border
dangerDanger-tinted background and border
ghostTransparent background and border

Presets

PresetDescription
icon-overflowZero-gap icon overflow trigger with standardized menu offset and width

Slots

SlotDescription
(default)Button label text
prefixIcon or element before the label
suffixIcon or element after the label
menucv-menu-item children for the dropdown menu

CSS Parts

PartElementDescription
base<div>Root layout wrapper
trigger<button>Full trigger button (standard mode only)
action<button>Primary action button (split mode only)
dropdown<button>Dropdown arrow button (split mode only)
label<span>Wrapper around the default slot
prefix<span>Wrapper around the prefix slot
suffix<span>Wrapper around the suffix slot
dropdown-icon<span>Dropdown arrow indicator
menu<div>Menu popup container

CSS Custom Properties

PropertyDefaultDescription
--cv-menu-button-min-height36pxMinimum block size of the trigger
--cv-menu-button-padding-inlinevar(--cv-space-3, 12px)Horizontal padding of the trigger
--cv-menu-button-padding-blockvar(--cv-space-2, 8px)Vertical padding of the trigger
--cv-menu-button-border-radiusvar(--cv-radius-sm, 6px)Border radius of the trigger
--cv-menu-button-gapvar(--cv-space-2, 8px)Gap between trigger content parts
--cv-menu-button-font-sizeinheritFont size of button content
--cv-menu-button-menu-offsetvar(--cv-space-1, 4px)Gap between trigger and menu popup
--cv-menu-button-menu-min-inline-sizemax(180px, 100%)Minimum inline size of the menu popup
--cv-menu-button-menu-max-inline-sizecalc(100vw - 16px)Maximum inline size of the menu popup
--cv-menu-button-menu-z-index20Z-index of the menu popup

Events

EventDetailDescription
cv-input{value, activeId, open}Fires on any state change (selection, active, open) forwarded from menu
cv-change{value, activeId, open}Fires only when the selected value changes
cv-action{}Fires when the action button is clicked in split-button mode

Visual States

Host selectorDescription
:host([open])Menu popup is visible
:host([disabled])Reduced opacity, cursor: not-allowed, all interaction blocked
:host([split])Split-button mode with separate action and dropdown areas
:host([size="small"])Small size overrides
:host([size="large"])Large size overrides
:host([variant="default"])Default surface background with border
:host([variant="primary"])Primary-tinted background and border
:host([variant="danger"])Danger-tinted background and border
:host([variant="ghost"])Transparent background and border

Reactive State Mapping

cv-menu-button is a visual adapter over headless createMenu.

UIKit PropertyDirectionHeadless Binding
valueattr -> actionactions.select(value) when value changes
openattr -> actionactions.open() when true; actions.close() when false
disabledattr -> DOMdisables trigger and blocks all interaction
splitattr -> optionpassed as splitButton in createMenu(options)
close-on-selectattr -> optionpassed as closeOnSelect in createMenu(options)
aria-labelattr -> optionpassed as ariaLabel in createMenu(options)
Headless StateDirectionDOM Reflection
state.isOpen()state -> attr[open] host attribute, menu [hidden]
state.activeId()state -> DOM[data-active] on item elements, focus management
state.selectedId()state -> attr[value] host attribute
state.openedBy()state -> DOMfocus management strategy (keyboard vs pointer)
state.restoreTargetId()state -> DOMfocus restored to trigger on close
state.checkedIds()state -> DOM[aria-checked] on checkbox/radio item elements
state.openSubmenuId()state -> DOMsubmenu container [hidden] state
state.submenuActiveId()state -> DOM[data-active] on submenu child items

Contracts applied to DOM elements:

  • contracts.getTriggerProps() -> trigger button ([part="trigger"]): provides id, tabindex, aria-haspopup, aria-expanded, aria-controls, aria-label
  • contracts.getMenuProps() -> menu container ([part="menu"]): provides id, role, tabindex, aria-label, aria-activedescendant
  • contracts.getItemProps(id) -> each item element: provides id, role, tabindex, aria-disabled, data-active, aria-checked, aria-haspopup, aria-expanded
  • contracts.getSplitTriggerProps() -> action button ([part="action"]): provides id, tabindex, role (only when split is true)
  • contracts.getSplitDropdownProps() -> dropdown button ([part="dropdown"]): provides id, tabindex, role, aria-haspopup, aria-expanded, aria-controls, aria-label (only when split is true)

UIKit does not own activation, navigation, toggle, or dismiss logic; headless state is the source of truth.

ARIA Contract

ElementAttributeValue
trigger (standard)aria-haspopupmenu
trigger (standard)aria-expandedtrue / false
trigger (standard)aria-controlsmenu element id
action (split)rolebutton
dropdown (split)aria-haspopupmenu
dropdown (split)aria-expandedtrue / false
dropdown (split)aria-controlsmenu element id
dropdown (split)aria-label"More options" or from aria-label attribute
menurolemenu
menutabindex-1
menuaria-activedescendantid of active item (when open)

Usage

View source
html
<!-- Basic menu button -->
<cv-menu-button>
  Actions
  <cv-menu-item slot="menu" value="cut">Cut</cv-menu-item>
  <cv-menu-item slot="menu" value="copy">Copy</cv-menu-item>
  <cv-menu-item slot="menu" value="paste">Paste</cv-menu-item>
</cv-menu-button>

<!-- With icon prefix -->
<cv-menu-button variant="primary">
  <icon-plus slot="prefix"></icon-plus>
  Create
  <cv-menu-item slot="menu" value="file">New File</cv-menu-item>
  <cv-menu-item slot="menu" value="folder">New Folder</cv-menu-item>
</cv-menu-button>

<!-- Small size -->
<cv-menu-button size="small">
  Options
  <cv-menu-item slot="menu" value="a">Option A</cv-menu-item>
  <cv-menu-item slot="menu" value="b">Option B</cv-menu-item>
</cv-menu-button>

<!-- Split button -->
<cv-menu-button split variant="primary">
  Save
  <cv-menu-item slot="menu" value="save-as">Save As...</cv-menu-item>
  <cv-menu-item slot="menu" value="save-copy">Save Copy</cv-menu-item>
  <cv-menu-item slot="menu" value="export">Export</cv-menu-item>
</cv-menu-button>

<!-- Disabled -->
<cv-menu-button disabled>
  Disabled
  <cv-menu-item slot="menu" value="a">Option A</cv-menu-item>
</cv-menu-button>

ChromVoid UIKit documentation