Skip to content

Menu

An action dropdown triggered by any slotted element. Presents a list of sg-menu-item actions in a floating panel using viewport-aware positioning. Supports full keyboard navigation and accessibility semantics.

Features

  • Any trigger: use any element in the trigger slot — button, icon, link
  • Full Keyboard Nav: ArrowDown/Up, Enter/Space, Escape, Tab, Home/End
  • Auto-positioning: uses @vielzeug/orbit to flip when near viewport edges
  • Outside-click close: dismiss by clicking anywhere outside
  • Separator: sg-menu-separator renders a visual divider between groups of items
  • Checkable Items: sg-menu-item supports type="checkbox" and type="radio" for toggleable selections
  • Disabled items: individual sg-menu-item items can be disabled
  • Icon slot: each item supports a leading icon slot
  • Color Themes: primary, secondary, info, success, warning, error
  • 3 Sizes: sm, md, lg
  • ARIA: role="menu", role="menuitem", aria-expanded, aria-haspopup="menu", aria-controls

Source Code

View Source Code
ts
import type { Placement } from '@vielzeug/orbit';

import { createStableId, define, html, prop, syncAria } from '@vielzeug/craft';
import { computed, watch as rippleWatch } from '@vielzeug/ripple';

import type { ComponentSize } from '../../types';

import {
  lifecycleSignal,
  createInteraction,
  createOptionList,
  type DropdownCloseReason,
  type OverlayOpenDetail,
} from '../../headless';
import { disablableBundle, sizableBundle } from '../../shared';
import { forcedColorsMixin, sizeVariantMixin } from '../../styles';
import menuItemStyles from './menu-item.css?inline';
import menuSeparatorStyles from './menu-separator.css?inline';
import componentStyles from './menu.css?inline';

// ── Types ─────────────────────────────────────────────────────────────

export interface MenuSelectDetail {
  value: string;
  checked?: boolean;
}

export type SgMenuItemType = 'checkbox' | 'radio';

export type SgMenuEvents = {
  close: { reason: DropdownCloseReason };
  open: OverlayOpenDetail;
  select: MenuSelectDetail;
};

export type SgMenuItemProps = {
  checked?: boolean;
  disabled?: boolean;
  type?: SgMenuItemType;
  value?: string;
};

export type SgMenuProps = {
  disabled?: boolean;
  placement?: 'bottom' | 'bottom-start' | 'bottom-end' | 'top' | 'top-start' | 'top-end';
  size?: ComponentSize;
};

// ── Styles ─────────────────────────────────────────────────────────────

// ── Menu Item Component ─────────────────────────────────────────────────────────────

/**
 * A selectable action item used inside `<sg-menu>`.
 *
 * @element sg-menu-item
 *
 * @attr {boolean} checked - Checked state for `checkbox` and `radio` item types
 * @attr {boolean} disabled - Disables selection and pointer interaction
 * @attr {'checkbox'|'radio'} type - Optional checkable menu item mode
 * @attr {string} value - Value emitted by parent menu on selection
 *
 * @slot - Item label/content
 * @slot icon - Optional leading icon content
 *
 * @cssprop --menu-item-hover-bg - Background on hover
 * @cssprop --menu-item-focus-color - Text color when keyboard-focused
 * @cssprop --menu-item-focus-bg - Background when keyboard-focused
 * @cssprop --menu-item-selection-bg - Background for checkbox/radio items (unselected)
 * @cssprop --menu-item-checked-color - Text color when checked
 * @cssprop --menu-item-checked-bg - Background when checked
 *
 * @part item - Root item container element.
 * @part item-label - Label text container.
 * @part icon-slot - Leading icon slot container.
 *
 * @example
 * ```html
 * <sg-menu-item value="edit">Edit</sg-menu-item>
 * <sg-menu-item value="delete" disabled>Delete</sg-menu-item>
 * <sg-menu-item type="checkbox" value="wrap" checked>Word wrap</sg-menu-item>
 * <sg-menu-item type="radio" value="left">Align left</sg-menu-item>
 * ```
 */
export const MENU_ITEM_TAG = 'sg-menu-item' as const;
define<SgMenuItemProps>(MENU_ITEM_TAG, {
  props: {
    checked: prop.bool(false),
    disabled: prop.bool(false),
    type: prop.string<SgMenuItemType>(),
    value: prop.string(),
  },
  setup(props) {
    const isCheckable = () => props.type.value === 'checkbox' || props.type.value === 'radio';
    const isChecked = () => isCheckable() && props.checked.value;
    const itemRole = () => {
      if (props.type.value === 'checkbox') return 'menuitemcheckbox';

      if (props.type.value === 'radio') return 'menuitemradio';

      return 'menuitem';
    };
    const itemClass = () => {
      const type = props.type.value;

      return [
        'item',
        type === 'checkbox' ? 'is-checkbox' : '',
        type === 'radio' ? 'is-radio' : '',
        isChecked() ? 'is-checked' : '',
      ]
        .filter(Boolean)
        .join(' ');
    };

    return isCheckable()
      ? html`
          <div
            class="${itemClass}"
            tabindex="-1"
            role="${itemRole}"
            aria-checked="${() => String(isChecked())}"
            aria-disabled="${props.disabled}">
            <span class="item-check" aria-hidden="true"></span>
            <span class="icon-slot"><slot name="icon"></slot></span>
            <span class="item-label"><slot></slot></span>
          </div>
        `
      : html`
          <div class="item" tabindex="-1" role="menuitem" aria-disabled="${props.disabled}">
            <span class="icon-slot"><slot name="icon"></slot></span>
            <span class="item-label"><slot></slot></span>
          </div>
        `;
  },
  styles: [menuItemStyles],
});

// ── Menu Separator ─────────────────────────────────────────────────────────────

/**
 * Visual separator used to group menu items inside `<sg-menu>`.
 *
 * @element sg-menu-separator
 *
 * @example
 * ```html
 * <sg-menu-item value="cut">Cut</sg-menu-item>
 * <sg-menu-separator></sg-menu-separator>
 * <sg-menu-item value="paste">Paste</sg-menu-item>
 * ```
 */
export const SEPARATOR_TAG = 'sg-menu-separator' as const;
define(SEPARATOR_TAG, {
  setup() {
    return html``;
  },
  styles: [menuSeparatorStyles],
});

// ── Menu Component ─────────────────────────────────────────────────────────────

const isCheckableItemType = (value: string | null): value is SgMenuItemType =>
  value === 'checkbox' || value === 'radio';

/**
 * Action dropdown menu triggered by a slotted trigger element.
 *
 * @element sg-menu
 * @element sg-menu-item - Clickable menu option (place in default slot)
 * @element sg-menu-separator - Visual divider between menu groups
 *
 * @attr {boolean} disabled - Disables opening and keyboard interaction
 * @attr {string} placement - Panel placement: 'bottom' | 'bottom-start' | 'bottom-end' | 'top' | 'top-start' | 'top-end' (default: 'bottom-start')
 * @attr {string} size - Size: 'sm' | 'md' | 'lg'
 *
 * @fires open - Fired when the menu opens. detail: { reason: 'trigger' | 'programmatic' }
 * @fires close - Fired when the menu closes. detail: { reason: 'escape' | 'outsideClick' | 'programmatic' | 'trigger' }
 * @fires select - Fired when an item is selected. detail: { value: string, checked?: boolean }
 *
 * @slot trigger - Trigger element that toggles menu visibility
 * @slot - Menu content (`<sg-menu-item>` and `<sg-menu-separator>`)
 *
 * @part panel - Floating menu panel container
 *
 * @cssprop --menu-panel-bg - Background of the floating panel
 * @cssprop --menu-panel-border-color - Border color of the floating panel
 * @cssprop --menu-panel-shadow - Box shadow of the floating panel
 * @cssprop --menu-panel-blur - Backdrop blur amount for the floating panel
 * @cssprop --menu-panel-min-width - Minimum width of the floating panel
 * @cssprop --menu-panel-radius - Border radius of the floating panel
 *
 * @example
 * ```html
 * <sg-menu>
 *   <button slot="trigger">Actions</button>
 *   <sg-menu-item value="edit">Edit</sg-menu-item>
 *   <sg-menu-item value="delete">Delete</sg-menu-item>
 * </sg-menu>
 * ```
 */
export const MENU_TAG = 'sg-menu' as const;
define<SgMenuProps, SgMenuEvents>(MENU_TAG, {
  props: {
    ...sizableBundle,
    ...disablableBundle,
    placement: prop.oneOf(
      ['bottom', 'bottom-start', 'bottom-end', 'top', 'top-start', 'top-end'] as const,
      'bottom-start',
    ),
  },
  setup(props, { bind, el, emit, onCleanup, onMounted, slots, watch }) {
    const menuId = createStableId('menu');
    const isDisabled = computed(() => Boolean(props.disabled.value));
    const abortSignal = lifecycleSignal(onCleanup);
    let triggerEl: HTMLElement | null = null;
    let panelEl: HTMLElement | null = null;
    let cleanupTrigger: (() => void) | null = null;

    // ── Helpers ───────────────────────────────────────────────────────────────
    function getItems(): HTMLElement[] {
      return Array.from(el.querySelectorAll<HTMLElement>('sg-menu-item:not([disabled])'));
    }

    function getItemFocusable(item: HTMLElement | null | undefined): HTMLElement | null {
      if (!item) return null;

      return item.shadowRoot?.querySelector<HTMLElement>('[role^="menuitem"]') ?? item;
    }

    function getFocusedItemIndex(): number {
      const items = getItems();

      return items.findIndex((item) => {
        const focusable = getItemFocusable(item);

        return item === document.activeElement || focusable === document.activeElement;
      });
    }

    const optionList = createOptionList<HTMLElement>({
      getBoundary: () => el,
      getItems: getItems,
      getPanel: () => panelEl,
      getReference: () => triggerEl,
      getTrigger: () => triggerEl,
      isDisabled: () => isDisabled.value,
      isItemDisabled: (item) => item.hasAttribute('disabled'),
      onClose: (reason) => emit('close', { reason }),
      onNavigate: (_action, index) => {
        const nextItem = getItems()[index];

        getItemFocusable(nextItem)?.focus();
      },
      onOpen: (reason) => emit('open', { reason }),
      positioning: {
        getPlacement: () => (props.placement.value ?? 'bottom-start') as Placement,
        matchWidth: false,
        offsetPx: 4,
        padding: 6,
      },
      signal: abortSignal,
    });
    const { isOpen: isOpenSignal } = optionList;

    const activateItem = (item: HTMLElement): void => {
      const type = item.getAttribute('type');
      const isCheckable = isCheckableItemType(type);

      if (type === 'checkbox') {
        item.toggleAttribute('checked', !item.hasAttribute('checked'));
      } else if (type === 'radio') {
        for (const radio of el.querySelectorAll<HTMLElement>('sg-menu-item[type="radio"]')) {
          radio.toggleAttribute('checked', radio === item);
        }
      }

      const value = item.getAttribute('value') ?? '';
      const checked = isCheckable ? item.hasAttribute('checked') : undefined;

      emit('select', { checked, value });

      if (!isCheckable) {
        optionList.close('programmatic');
      }
    };

    const openFromKeyboardPress = createInteraction({
      keys: ['Enter', ' ', 'ArrowDown'],
      onPress: () => {
        optionList.open('keyboard');
        requestAnimationFrame(() => optionList.set(0));
      },
    });

    const activateFocusedFromKeyboardPress = createInteraction({
      onPress: () => {
        const focused = optionList.getActiveItem();

        if (focused) activateItem(focused);
      },
    });

    // ── Keyboard Navigation ───────────────────────────────────────────────────
    function handleMenuKeydown(e: KeyboardEvent) {
      if (isDisabled.value) return;

      const open = isOpenSignal.value;

      // When closed: open on Enter / Space / ArrowDown
      if (!open) {
        openFromKeyboardPress.handleKeydown(e);

        return;
      }

      const currentFocusedIndex = getFocusedItemIndex();

      if (currentFocusedIndex >= 0) optionList.set(currentFocusedIndex);

      if (optionList.handleKeydown(e)) return;

      // When open: navigate and activate
      if (e.key === ' ' || e.key === 'Enter') {
        activateFocusedFromKeyboardPress.handleKeydown(e);

        return;
      }

      if (e.key === 'Tab') {
        optionList.close('programmatic');
      }
    }

    // ── Lifecycle ─────────────────────────────────────────────────────────────
    bind({
      on: {
        click: (e: MouseEvent) => {
          const path = e.composedPath();

          if (!isOpenSignal.value) return;

          const itemFromPath = path.find(
            (node): node is HTMLElement => node instanceof HTMLElement && node.tagName === 'SG-MENU-ITEM',
          );
          const item = itemFromPath ?? (e.target as HTMLElement | null)?.closest<HTMLElement>('sg-menu-item') ?? null;

          if (!item || item.hasAttribute('disabled')) return;

          activateItem(item);
        },
      },
    });

    watch(() => {
      const open = isOpenSignal.value;

      if (!panelEl) return;

      panelEl.toggleAttribute('data-open', open);
    });

    function resolveTrigger() {
      cleanupTrigger?.();
      cleanupTrigger = null;

      const assigned = slots.elements('trigger').value;

      triggerEl = (assigned?.[0] as HTMLElement | undefined) ?? null;

      if (!triggerEl) return;

      const cleanups: Array<() => void> = [];
      const removeAria = syncAria(
        triggerEl,
        {
          controls: () => menuId,
          disabled: () => isDisabled.value,
          expanded: () => String(isOpenSignal.value),
          haspopup: 'menu',
        },
        { autoCleanup: false },
      );

      const onTriggerClick = (event: MouseEvent) => {
        event.stopPropagation();

        if (isDisabled.value) return;

        optionList.toggle();
      };
      const onTriggerKeydown = (event: KeyboardEvent) => {
        handleMenuKeydown(event);
      };

      triggerEl.addEventListener('click', onTriggerClick);
      triggerEl.addEventListener('keydown', onTriggerKeydown);
      cleanups.push(() => triggerEl?.removeEventListener('click', onTriggerClick));
      cleanups.push(() => triggerEl?.removeEventListener('keydown', onTriggerKeydown));

      cleanupTrigger = () => {
        removeAria();

        for (const cleanup of cleanups) cleanup();
      };
    }

    rippleWatch(slots.elements('trigger'), resolveTrigger, { immediate: true });

    onMounted(() => {
      return () => {
        cleanupTrigger?.();
        cleanupTrigger = null;
      };
    });

    return html`
      <slot name="trigger"></slot>
      <div
        class="menu-panel"
        part="panel"
        id="${menuId}"
        role="menu"
        aria-orientation="vertical"
        @keydown="${handleMenuKeydown}"
        ref="${(el: HTMLElement | null) => {
          panelEl = el;
        }}">
        <slot></slot>
      </div>
    `;
  },
  styles: [componentStyles, sizeVariantMixin(), forcedColorsMixin],
});

Basic Usage

Place your trigger element in the trigger slot and add sg-menu-item children.

html
<sg-menu>
  <sg-button slot="trigger">Actions</sg-button>
  <sg-menu-item value="edit">Edit</sg-menu-item>
  <sg-menu-item value="duplicate">Duplicate</sg-menu-item>
  <sg-menu-item value="delete">Delete</sg-menu-item>
</sg-menu>

Placement

Control which side of the trigger the panel opens on. The menu automatically flips to avoid viewport clipping.

PreviewCode
RTL

Items with Icons

Use the icon named slot on each sg-menu-item for leading icons.

Icons

These examples use inline SVG slot content so they stay framework and icon-library agnostic.

PreviewCode
RTL

Disabled Items

Set disabled on a sg-menu-item to prevent selection. The item is still visible but non-interactive.

PreviewCode
RTL

Disabled Menu

Set disabled on the sg-menu element to prevent the panel from opening at all.

PreviewCode
RTL

Listening to Events

html
<sg-menu id="action-menu">
  <sg-button slot="trigger">Actions</sg-button>
  <sg-menu-item value="rename">Rename</sg-menu-item>
  <sg-menu-item value="move">Move to folder</sg-menu-item>
  <sg-menu-item value="delete">Delete</sg-menu-item>
</sg-menu>

<script type="module">
  import '@vielzeug/sigil/menu';
  import '@vielzeug/sigil/button';

  const menu = document.getElementById('action-menu');

  // Fired when a menu item is selected
  menu.addEventListener('select', (e) => {
    console.log('selected:', e.detail.value, 'checked:', e.detail.checked);
    switch (e.detail.value) {
      case 'rename':
        openRenameDialog();
        break;
      case 'move':
        openMoveDialog();
        break;
      case 'delete':
        confirmDelete();
        break;
    }
  });

  // Fired when the panel opens
  menu.addEventListener('open', (e) => {
    console.log('menu opened via:', e.detail.reason); // 'programmatic' or 'trigger'
  });

  // Fired when the panel closes
  menu.addEventListener('close', (e) => {
    console.log('menu closed via:', e.detail.reason); // 'escape', 'outside-click', 'programmatic', or 'trigger'
  });
</script>

Trigger with an Icon Button

Any element works as the trigger — including icon-only buttons.

PreviewCode
RTL

Separator

Use sg-menu-separator to add a horizontal divider between groups of related items.

PreviewCode
RTL

Checkable Items

Checkbox Items

Set type="checkbox" on a sg-menu-item to make it toggleable. Clicking or pressing Enter/Space toggles the checked attribute and emits select with checked in the event detail. The menu stays open when a checkbox item is activated.

PreviewCode
RTL

Radio Items

Set type="radio" to create a group where only one item can be checked at a time. Selecting a radio item automatically unchecks all other type="radio" siblings.

PreviewCode
RTL

API Reference

sg-menu Attributes

AttributeTypeDefaultDescription
placement'bottom' | 'bottom-start' | 'bottom-end' | 'top' | 'top-start' | 'top-end''bottom-start'Preferred panel placement (auto-flips near viewport edges)
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'Color theme
size'sm' | 'md' | 'lg''md'Size theme
disabledbooleanfalsePrevent the menu from opening

sg-menu Slots

SlotDescription
triggerThe element that opens/closes the menu panel
(default)sg-menu-item elements to display as menu options

sg-menu-item Attributes

AttributeTypeDefaultDescription
valuestring''Value emitted in the select event detail
type'checkbox' | 'radio'Makes the item checkable; radio items are mutually exclusive
checkedbooleanfalseWhether a checkable item is currently checked
disabledbooleanfalsePrevent the item from being selected

sg-menu-item Slots

SlotDescription
iconOptional leading icon or decoration
(default)Item label text

Events

EventDetailDescription
select{ value: string, checked?: boolean }Emitted when a menu item is selected. checked is present for type="checkbox" and type="radio" items
open{ reason: 'programmatic' | 'trigger' }Emitted when the panel opens.
close{ reason: 'escape' | 'outside-click' | 'programmatic' | 'trigger' }Emitted when the panel closes.

CSS Custom Properties (sg-menu)

PropertyDescriptionDefault
--menu-panel-min-widthMinimum width of the panel10rem
--menu-panel-radiusBorder radius of the panelvar(--rounded-lg)
--menu-panel-shadowBox shadow of the panelvar(--shadow-xl)
--menu-panel-bgPanel background surfaceTheme-dependent
--menu-panel-border-colorPanel border colorTheme-dependent
--menu-panel-blurPanel backdrop blur amountvar(--blur-md)
--menu-item-hover-bgItem background on hoverTheme-dependent
--menu-item-focus-colorItem text color when keyboard-focusedTheme-dependent
--menu-item-focus-bgItem background when keyboard-focusedTheme-dependent
--menu-item-selection-bgBackground for checkbox/radio items (unselected)Theme-dependent
--menu-item-checked-colorItem text color when checkedTheme-dependent
--menu-item-checked-bgItem background when checkedTheme-dependent

Accessibility

The menu component follows WAI-ARIA Menu Button Pattern best practices.

sg-menu

Keyboard Navigation
  • Arrow keys move focus between items; Enter / Space activates; Escape closes and returns focus to the trigger.
  • Home / End jump to the first or last item.
  • Outside clicks and Tab close the menu and restore focus to the trigger.
Screen Readers
  • The panel has role="menu" and aria-orientation="vertical".
  • The trigger element receives aria-haspopup="menu", aria-expanded, and aria-controls pointing to the menu panel.

sg-menu-item

Screen Readers
  • Each item has role="menuitem" and aria-disabled when disabled.
  • Checkable items automatically switch to role="menuitemcheckbox" or role="menuitemradio" with the appropriate aria-checked value.

TIP

Always provide a visible label or aria-label on an icon-only trigger button so the purpose is clear to screen reader users.

Best Practices

Do:

  • Keep menu items short and action-oriented (verb + noun: "Edit post", "Delete file").
  • Use value on each item to handle selection in a single sg-select listener rather than per-item click handlers.
  • Use disabled on items for permissions that might change, rather than removing the item, to signal that the action exists but is unavailable.

Don't:

  • Nest menus inside other menus — this creates complex keyboard interactions and poor UX.
  • Place form controls (inputs, checkboxes) inside a menu — use a popover or dialog instead.
  • Use a menu when only one action is available — a plain button is always clearer.