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