Skip to content

Menu

An action dropdown triggered by any slotted element. Presents a list of bit-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/floatit to flip when near viewport edges
  • 🔕 Outside-click close: dismiss by clicking anywhere outside
  • Separator: bit-menu-separator renders a visual divider between groups of items
  • Checkable Items: bit-menu-item supports type="checkbox" and type="radio" for toggleable selections
  • 🙅 Disabled items: individual bit-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 {
  computed,
  createId,
  css,
  define,
  effect,
  html,
  prop,
  signal,
  watch,
  onMounted,
  syncAria,
} from '@vielzeug/craftit';
import {
  createPopupListControl,
  createPressControl,
  type OverlayCloseDetail,
  type OverlayOpenDetail,
} from '@vielzeug/craftit/controls';
import { computePosition, flip, offset, shift } from '@vielzeug/floatit';

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

import { disablableBundle, sizableBundle, themableBundle } from '../../inputs/shared/bundles';
import { coarsePointerMixin, colorThemeMixin, forcedColorsMixin, sizeVariantMixin } from '../../styles';
import componentStyles from './menu.css?inline';

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

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

export type BitMenuEvents = {
  close: OverlayCloseDetail;
  open: OverlayOpenDetail;
  select: MenuSelectDetail;
};

export type BitMenuItemProps = {
  checked?: boolean;
  disabled?: boolean;
  type?: BitMenuItemType;
  value?: string;
};

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

// ============================================
// Styles
// ============================================

const themeStyles = /* css */ css`
  ${colorThemeMixin}
  ${sizeVariantMixin}
  ${forcedColorsMixin}
`;

// ============================================
// Menu Item Component
// ============================================

const menuItemProps = {
  checked: false,
  disabled: false,
  type: undefined,
  value: undefined,
};

/**
 * A selectable action item used inside `<bit-menu>`.
 *
 * @element bit-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
 */
export const MENU_ITEM_TAG = define<BitMenuItemProps>('bit-menu-item', {
  props: menuItemProps,
  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 checkIndicator = () => {
      if (props.type.value === 'checkbox') return props.checked.value ? '☑' : '☐';

      if (props.type.value === 'radio') return props.checked.value ? '◉' : '◯';

      return '';
    };

    return () => html`
      <style>
        @layer buildit.base {
          :host {
            display: block;
            outline: none;
          }

          .item {
            align-items: center;
            border-radius: 0;
            cursor: pointer;
            display: flex;
            font-size: var(--text-sm);
            gap: var(--size-2);
            line-height: var(--leading-normal);
            padding: var(--size-1-5) var(--size-3);
            transition:
              background var(--transition-fast),
              color var(--transition-fast);
            user-select: none;
            white-space: nowrap;
          }

          :host(:first-of-type) .item {
            border-radius: var(--rounded-sm) var(--rounded-sm) 0 0;
          }

          :host(:last-child) .item {
            border-radius: 0 0 var(--rounded-sm) var(--rounded-sm);
          }

          :host(:first-of-type:last-child) .item {
            border-radius: var(--rounded-sm);
          }

          :host(:not([disabled])) .item:hover {
            background: var(--color-contrast-100);
          }

          :host(:focus-visible) .item {
            background: color-mix(in srgb, var(--color-primary) 12%, var(--color-contrast-100));
            color: var(--color-primary);
          }

          /* Driven by JS via sync() — avoids :host() attribute selector edge-cases */
          .item.is-checkable {
            background: color-mix(in srgb, var(--color-contrast-900) 5%, var(--color-canvas));
          }

          .item.is-checked {
            background: color-mix(in srgb, var(--color-primary) 18%, var(--color-canvas));
            color: var(--color-primary);
            font-weight: var(--font-medium);
          }

          :host([disabled]) .item {
            color: var(--color-contrast-400);
            cursor: not-allowed;
            opacity: 0.6;
            pointer-events: none;
          }

          .icon-slot {
            display: contents;
          }

          .item-check {
            align-items: center;
            color: currentColor;
            display: inline-flex;
            flex-shrink: 0;
            justify-content: center;
            width: 1.25rem;
          }

          .item-label {
            flex: 1;
            min-width: 0;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
          }
        }
        ${coarsePointerMixin}
      </style>
      ${() =>
        isCheckable()
          ? html`
              <div
                class="${() => `item${isCheckable() ? ' is-checkable' : ''}${isChecked() ? ' is-checked' : ''}`}"
                tabindex="-1"
                role="${itemRole}"
                aria-checked="${() => String(isChecked())}"
                aria-disabled="${props.disabled}">
                <span class="item-check" aria-hidden="true">${checkIndicator}</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>
            `}
    `;
  },
});

// ============================================
// Menu Separator
// ============================================

/**
 * Visual separator used to group menu items.
 *
 * @element bit-menu-separator
 */
export const SEPARATOR_TAG = define('bit-menu-separator', {
  setup() {
    return () =>
      html`<style>
        @layer buildit.base {
          :host {
            display: block;
            margin: var(--size-1) 0;
            border-top: var(--border) solid var(--color-contrast-200);
          }
        }
      </style>`;
  },
});

// ============================================
// Menu Component
// ============================================

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

/**
 * Action dropdown menu triggered by a slotted trigger element.
 *
 * @element bit-menu
 *
 * @attr {string} color - Theme color variant for menu styling
 * @attr {boolean} disabled - Disables opening and keyboard interaction
 * @attr {string} placement - Floating panel placement around the trigger
 * @attr {string} size - Size variant propagated to menu styling tokens
 *
 * @fires open - Fired when the menu opens (`detail.reason` explains source)
 * @fires close - Fired when the menu closes (`detail.reason` explains source)
 * @fires select - Fired when an item is selected (`detail.value`, optional `detail.checked`)
 *
 * @slot trigger - Trigger element that toggles menu visibility
 * @slot - Menu content (`<bit-menu-item>` and `<bit-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
 * <bit-menu>
 *   <button slot="trigger">Actions</button>
 *   <bit-menu-item value="edit">Edit</bit-menu-item>
 *   <bit-menu-item value="delete">Delete</bit-menu-item>
 * </bit-menu>
 * ```
 */
export const MENU_TAG = define<BitMenuProps, BitMenuEvents>('bit-menu', {
  props: {
    ...themableBundle,
    ...sizableBundle,
    ...disablableBundle,
    placement: prop.oneOf(
      ['bottom', 'bottom-start', 'bottom-end', 'top', 'top-start', 'top-end'] as const,
      'bottom-start',
    ),
  },
  setup(props, { emit, host, slots }) {
    const menuId = createId('menu');
    const isOpenSignal = signal(false);
    const isDisabled = computed(() => Boolean(props.disabled.value));
    let triggerEl: HTMLElement | null = null;
    let panelEl: HTMLElement | null = null;
    let cleanupTrigger: (() => void) | null = null;

    // ── Helpers ───────────────────────────────────────────────────────────────
    function getItems(): HTMLElement[] {
      return Array.from(host.el.querySelectorAll<HTMLElement>('bit-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;
      });
    }

    let focusedIndex = -1;

    function updatePosition() {
      if (!panelEl || !triggerEl) return;

      const result = computePosition(triggerEl, panelEl, {
        middleware: [offset(4), flip({ padding: 6 }), shift({ padding: 6 })],
        placement: props.placement.value,
      });

      panelEl.style.left = `${result.x}px`;
      panelEl.style.top = `${result.y}px`;
    }

    const triggerRef = { value: null as HTMLElement | null };

    const popupList = createPopupListControl({
      ariaSync: { role: 'menu' },
      getBoundaryElement: () => host.el,
      getIndex: () => focusedIndex,
      getItems: getItems,
      getPanelElement: () => panelEl,
      getTriggerElement: () => triggerEl,
      isDisabled: () => isDisabled.value,
      isItemDisabled: (item) => item.hasAttribute('disabled'),
      isOpen: () => isOpenSignal.value,
      listId: menuId,
      onClose: (reason) => emit('close', { reason }),
      onOpen: (reason) => emit('open', { reason }),
      positioner: {
        floating: () => panelEl,
        reference: () => triggerEl,
        update: updatePosition,
      },
      setIndex: (index) => {
        focusedIndex = index;

        const nextItem = getItems()[index];

        getItemFocusable(nextItem)?.focus();
      },
      setOpen: (next) => {
        isOpenSignal.value = next;
      },
      triggerRef,
    });

    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 host.el.querySelectorAll<HTMLElement>('bit-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) {
        popupList.close('programmatic');
      }
    };

    const openFromKeyboardPress = createPressControl({
      keys: ['Enter', ' ', 'ArrowDown'],
      onPress: () => {
        popupList.open('trigger');
        requestAnimationFrame(() => popupList.first());
      },
    });

    const activateFocusedFromKeyboardPress = createPressControl({
      onPress: () => {
        const focused = popupList.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) focusedIndex = currentFocusedIndex;

      if (popupList.handleListKeydown(e)) return;

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

        return;
      }

      if (e.key === 'Escape') {
        e.preventDefault();
        popupList.close('escape');

        return;
      }

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

    // ── Lifecycle ─────────────────────────────────────────────────────────────
    host.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 === 'BIT-MENU-ITEM',
          );
          const item = itemFromPath ?? (e.target as HTMLElement | null)?.closest<HTMLElement>('bit-menu-item') ?? null;

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

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

    effect(() => {
      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;
      triggerRef.value = triggerEl;

      if (!triggerEl) return;

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

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

        if (isDisabled.value) return;

        popupList.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();
      };
    }

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

    onMounted(() => {
      return () => {
        cleanupTrigger?.();
        cleanupTrigger = null;
        triggerRef.value = 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, themeStyles],
});

Basic Usage

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

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

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

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 bit-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 bit-menu-item to prevent selection. The item is still visible but non-interactive.

PreviewCode
RTL

Disabled Menu

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

PreviewCode
RTL

Listening to Events

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

<script type="module">
  import '@vielzeug/buildit/menu';
  import '@vielzeug/buildit/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 bit-menu-separator to add a horizontal divider between groups of related items.

PreviewCode
RTL

Checkable Items

Checkbox Items

Set type="checkbox" on a bit-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

bit-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

bit-menu Slots

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

bit-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

bit-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 (bit-menu)

PropertyDescriptionDefault
--menu-panel-min-widthMinimum width of the panel10rem
--menu-panel-radiusBorder radius of the panel--rounded-lg
--menu-panel-shadowBox shadow of the panel--shadow-xl
--menu-panel-bgPanel background surfacemixed contrast surface
--menu-panel-border-colorPanel border colorsubtle mixed contrast
--menu-panel-blurPanel blur amount--blur-md

Accessibility

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

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

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