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 triggerslot — button, icon, linkFull Keyboard Nav: ArrowDown/Up, Enter/Space, Escape, Tab, Home/End Auto-positioning: uses @vielzeug/orbitto flip when near viewport edgesOutside-click close: dismiss by clicking anywhere outside Separator: sg-menu-separatorrenders a visual divider between groups of itemsCheckable Items: sg-menu-itemsupportstype="checkbox"andtype="radio"for toggleable selectionsDisabled items: individual sg-menu-itemitems can be disabledIcon slot: each item supports a leading iconslotColor 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 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.
<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.
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.
Disabled Items
Set disabled on a sg-menu-item to prevent selection. The item is still visible but non-interactive.
Disabled Menu
Set disabled on the sg-menu element to prevent the panel from opening at all.
Listening to Events
<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.
Separator
Use sg-menu-separator to add a horizontal divider between groups of related items.
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.
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
sg-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 |
sg-menu Slots
| Slot | Description |
|---|---|
trigger | The element that opens/closes the menu panel |
| (default) | sg-menu-item elements to display as menu options |
sg-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 |
sg-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 (sg-menu)
| Property | Description | Default |
|---|---|---|
--menu-panel-min-width | Minimum width of the panel | 10rem |
--menu-panel-radius | Border radius of the panel | var(--rounded-lg) |
--menu-panel-shadow | Box shadow of the panel | var(--shadow-xl) |
--menu-panel-bg | Panel background surface | Theme-dependent |
--menu-panel-border-color | Panel border color | Theme-dependent |
--menu-panel-blur | Panel backdrop blur amount | var(--blur-md) |
--menu-item-hover-bg | Item background on hover | Theme-dependent |
--menu-item-focus-color | Item text color when keyboard-focused | Theme-dependent |
--menu-item-focus-bg | Item background when keyboard-focused | Theme-dependent |
--menu-item-selection-bg | Background for checkbox/radio items (unselected) | Theme-dependent |
--menu-item-checked-color | Item text color when checked | Theme-dependent |
--menu-item-checked-bg | Item background when checked | Theme-dependent |
Accessibility
The menu component follows WAI-ARIA Menu Button Pattern best practices.
sg-menu
- 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.
- The panel has
role="menu"andaria-orientation="vertical". - The trigger element receives
aria-haspopup="menu",aria-expanded, andaria-controlspointing to the menu panel.
sg-menu-item
- 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 singlesg-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.