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
triggerslot — button, icon, link - ⌨️ Full Keyboard Nav: ArrowDown/Up, Enter/Space, Escape, Tab, Home/End
- 📍 Auto-positioning: uses
@vielzeug/floatitto flip when near viewport edges - 🔕 Outside-click close: dismiss by clicking anywhere outside
- ➖ Separator:
bit-menu-separatorrenders a visual divider between groups of items - ✅ Checkable Items:
bit-menu-itemsupportstype="checkbox"andtype="radio"for toggleable selections - 🙅 Disabled items: individual
bit-menu-itemitems can be disabled - 🧩 Icon slot: each item supports a leading
iconslot - 🎨 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
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.
<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.
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.
Disabled Items
Set disabled on a bit-menu-item to prevent selection. The item is still visible but non-interactive.
Disabled Menu
Set disabled on the bit-menu element to prevent the panel from opening at all.
Listening to Events
<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.
Separator
Use bit-menu-separator to add a horizontal divider between groups of related items.
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.
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.
API Reference
bit-menu Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
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 |
disabled | boolean | false | Prevent the menu from opening |
bit-menu Slots
| Slot | Description |
|---|---|
trigger | The element that opens/closes the menu panel |
| (default) | bit-menu-item elements to display as menu options |
bit-menu-item Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value | string | '' | Value emitted in the select event detail |
type | 'checkbox' | 'radio' | — | Makes the item checkable; radio items are mutually exclusive |
checked | boolean | false | Whether a checkable item is currently checked |
disabled | boolean | false | Prevent the item from being selected |
bit-menu-item Slots
| Slot | Description |
|---|---|
icon | Optional leading icon or decoration |
| (default) | Item label text |
Events
| Event | Detail | Description |
|---|---|---|
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)
| Property | Description | Default |
|---|---|---|
--menu-panel-min-width | Minimum width of the panel | 10rem |
--menu-panel-radius | Border radius of the panel | --rounded-lg |
--menu-panel-shadow | Box shadow of the panel | --shadow-xl |
--menu-panel-bg | Panel background surface | mixed contrast surface |
--menu-panel-border-color | Panel border color | subtle mixed contrast |
--menu-panel-blur | Panel 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/Spaceactivates;Escapecloses and returns focus to the trigger. Home/Endjump to the first or last item.- Outside clicks and
Tabclose the menu and restore focus to the trigger.
✅ Screen Readers
- The panel has
role="menu"andaria-orientation="vertical". - The trigger element receives
aria-haspopup="menu",aria-expanded, andaria-controlspointing to the menu panel.
bit-menu-item
✅ Screen Readers
- Each item has
role="menuitem"andaria-disabledwhen disabled. - Checkable items automatically switch to
role="menuitemcheckbox"orrole="menuitemradio"with the appropriatearia-checkedvalue.
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
valueon each item to handle selection in a singlebit-selectlistener rather than per-item click handlers. - Use
disabledon 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.