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 {
  define,
  createCleanupSignal,
  computed,
  createId,
  css,
  effect,
  handle,
  html,
  onMount,
  signal,
  watch,
} from '@vielzeug/craftit';
import {
  createListKeyControl,
  createPressControl,
  createListControl,
  createOverlayControl,
  type OverlayCloseDetail,
  type OverlayOpenDetail,
} from '@vielzeug/craftit/controls';
import { flip, offset, positionFloat, shift } from '@vielzeug/floatit';

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

import { disablableBundle, sizableBundle, themableBundle, type PropBundle } from '../../inputs/shared/bundles';
import { coarsePointerMixin, colorThemeMixin, forcedColorsMixin, sizeVariantMixin } from '../../styles';
import { syncAria } from '../../utils/aria';

// ============================================
// 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
// ============================================

import componentStyles from './menu.css?inline';

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

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

const menuItemProps = {
  checked: false,
  disabled: false,
  type: undefined,
  value: undefined,
} satisfies PropBundle<BitMenuItemProps>;

export const MENU_ITEM_TAG = define<BitMenuItemProps>('bit-menu-item', {
  props: menuItemProps,
  setup({ props }) {
    const itemStyles = /* css */ css`
      @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}
    `;

    const isCheckable = computed(() => props.type.value === 'checkbox' || props.type.value === 'radio');
    const isChecked = computed(() => isCheckable.value && props.checked.value);
    const itemRole = computed(() => {
      if (props.type.value === 'checkbox') return 'menuitemcheckbox';

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

      return 'menuitem';
    });
    const checkIndicator = computed(() => {
      if (props.type.value === 'checkbox') return props.checked.value ? '☑' : '☐';

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

      return '';
    });
    const itemClass = computed(
      () => `item${isCheckable.value ? ' is-checkable' : ''}${isChecked.value ? ' is-checked' : ''}`,
    );
    const renderContent = () => html`
      <span class="item-check" aria-hidden="true">${() => checkIndicator.value}</span>
      <span class="icon-slot"><slot name="icon"></slot></span>
      <span class="item-label"><slot></slot></span>
    `;

    return html`
      <style>
        ${itemStyles}
      </style>
      ${() =>
        isCheckable.value
          ? html`
              <div
                class="${() => itemClass.value}"
                tabindex="-1"
                role="${() => itemRole.value}"
                aria-checked="${() => String(isChecked.value)}"
                aria-disabled="${() => String(props.disabled.value)}">
                ${renderContent()}
              </div>
            `
          : html`
              <div
                class="${() => itemClass.value}"
                tabindex="-1"
                role="menuitem"
                aria-disabled="${() => String(props.disabled.value)}">
                <span class="icon-slot"><slot name="icon"></slot></span>
                <span class="item-label"><slot></slot></span>
              </div>
            `}
    `;
  },
});

// ============================================
// 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 menuProps = {
  ...themableBundle,
  ...sizableBundle,
  ...disablableBundle,
  placement: 'bottom-start',
} satisfies PropBundle<BitMenuProps>;

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

/**
 * `bit-menu` — Action dropdown menu triggered by a slotted trigger element.
 * Nest `<bit-menu-item>` elements inside for menu options.
 *
 * @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: menuProps,
  setup({ emit, host, props, 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;

    // ── 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;

    const listNavigation = createListControl<HTMLElement>({
      getIndex: () => focusedIndex,
      getItems,
      isItemDisabled: (item) => item.hasAttribute('disabled'),
      setIndex: (index) => {
        focusedIndex = index;

        const nextItem = getItems()[index];

        getItemFocusable(nextItem)?.focus();
      },
    });

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

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

    const overlay = createOverlayControl({
      disabled: isDisabled,
      elements: {
        boundary: host.el,
        panel: panelEl,
        trigger: triggerEl,
      },
      isOpen: isOpenSignal,
      onClose: (reason) => emit('close', { reason }),
      onOpen: (reason) => emit('open', { reason }),
      positioner: {
        floating: () => panelEl,
        reference: () => triggerEl,
        update: updatePosition,
      },
      setOpen: (next) => {
        isOpenSignal.value = next;

        if (!next) listNavigation.reset();
      },
    });

    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) {
        overlay.close('programmatic');
      }
    };

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

    const openListKeys = createListKeyControl({
      control: listNavigation,
      disabled: () => !isOpenSignal.value,
    });

    const activateFocusedFromKeyboardPress = createPressControl({
      onPress: () => {
        const focused = listNavigation.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 (openListKeys.handleKeydown(e)) return;

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

        return;
      }

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

        return;
      }

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

    // ── Lifecycle ─────────────────────────────────────────────────────────────
    onMount(() => {
      const triggerSlot = host.shadowRoot?.querySelector<HTMLSlotElement>('slot[name="trigger"]');

      panelEl = host.shadowRoot?.querySelector<HTMLElement>('.menu-panel') ?? null;

      effect(() => {
        if (!panelEl) return;

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

      const triggerBinding = createCleanupSignal();

      function resolveTrigger() {
        const assigned = triggerSlot?.assignedElements({ flatten: true });

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

        if (triggerEl) {
          const trigger = triggerEl;

          const removeAria = syncAria(trigger, {
            controls: () => menuId,
            disabled: () => isDisabled.value,
            expanded: () => (isOpenSignal.value ? 'true' : 'false'),
            haspopup: 'menu',
          });

          trigger.addEventListener('click', toggleMenu);
          trigger.addEventListener('keydown', handleMenuKeydown);

          triggerBinding.set(() => {
            removeAria();
            trigger.removeEventListener('click', toggleMenu);
            trigger.removeEventListener('keydown', handleMenuKeydown);
          });
        } else {
          triggerBinding.clear();
        }
      }

      function toggleMenu() {
        if (isDisabled.value) return;

        overlay.toggle();
      }

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

      const removeOutsideClick = overlay.bindOutsideClick(document);

      handle(panelEl, 'keydown', handleMenuKeydown as EventListener);

      return () => {
        removeOutsideClick();
        triggerBinding.clear();
      };
    });

    host.bind('on', {
      click: (e) => {
        if (!isOpenSignal.value) return;

        const path = e.composedPath();
        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);
      },
    });

    return html`
      <style>
        ${componentStyles}${themeStyles}
      </style>
      <slot name="trigger"></slot>
      <div class="menu-panel" id="${menuId}" role="menu" aria-orientation="vertical">
        <slot></slot>
      </div>
    `;
  },
});

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.