Zero-dependency dropdown action menus with the full menu-button keyboard pattern — arrows, typeahead, submenus, context menus. Try the keyboard: focus a trigger and press ArrowDown.
<script src="https://cdn.jsdelivr.net/gh/vanilla-ui-kit/components/menu/menu.js"></script>
A trigger button and a list of actions. Esc closes, outside click closes.
new Menu('#m-basic', {
items: [
{ label: 'Rename', onSelect: fn },
{ label: 'Duplicate', onSelect: fn },
{ label: 'Move to…', onSelect: fn }
]
})
Trusted SVG icons, right-aligned hints, red destructive actions; disabled items are skipped by the arrow keys.
{ label: 'Copy', icon: svg, hint: '⌘C' }
{ label: 'Paste', disabled: true }
{ label: 'Delete', danger: true,
hint: '⌫', onSelect: fn }
{ type: 'separator' } groups related actions.
items: [
{ label: 'New file' },
{ label: 'New window' },
{ type: 'separator' },
{ label: 'Close' }
]
One level of flyout — hover or ArrowRight to open, ArrowLeft to come back.
{ label: 'Share',
items: [
{ label: 'Copy link' },
{ label: 'Email' },
{ label: 'Embed' }
] }
Right-click (or long-press) the area — Menu.open(x, y, items)
flips inward at viewport edges.
el.addEventListener('contextmenu', e => {
e.preventDefault()
Menu.open(e.clientX, e.clientY, items)
})
data-vmn points at a template; no JS needed.
Selections fire a menu:select event.
<button data-vmn="#tpl-view">View</button> <template id="tpl-view"> <button>Zoom in</button> <hr> <button data-hint="⌘0">Reset zoom</button> </template>
new Menu(trigger, options?) // trigger: element or CSS selector of a <button> Menu.open(x, y, items | opts) // one-shot menu at viewport coordinates <button data-vmn="#tpl">… // declarative, items from a <template>
Menu.open(e.clientX, e.clientY, items) from a contextmenu handler; flips inward at viewport edges and destroys itself on close.data-vmn points at a template holding the items (see Declarative init).Menus are for actions — if you’re picking a value, you want Select. Only one root menu is open at a time. Also works with CommonJS/AMD and is SSR-safe (no-op without a DOM).
Second constructor argument; all keys of Menu.defaults. Change a default for every menu with, e.g., Menu.defaults.placement = 'bottom-end'.
| Name | Type | Default | Description |
|---|---|---|---|
items | array | [] | The item list — see Items below. |
placement | string | 'bottom-start' | bottom/top + -start/-end; flips when out of room, clamps to the viewport. |
closeOnSelect | boolean | true | Close after an item is activated. |
theme | string | 'auto' | 'auto' | 'light' | 'dark' (see Theming). |
styles | boolean | true | false = headless, no CSS ever injected (see Theming). |
onOpen | function | null | fn(menu) — see Events & callbacks. |
onClose | function | null | fn(menu) — see Events & callbacks. |
Each entry of items is an action object, a separator { type: 'separator' }, or a submenu parent (an item with its own items — one level only, opened by hover or ArrowRight):
| Property | Type | Description |
|---|---|---|
label | string | Item text — always rendered with textContent. |
icon | string | Optional; trusted inline SVG markup, like Toast’s icons. |
hint | string | Optional right-aligned kbd/hint text, e.g. '⌘D'. |
danger | boolean | Red, for destructive actions. |
disabled | boolean | aria-disabled, skipped by the arrow keys. |
onSelect | function | fn(item, menu) when the item is activated. |
items | array | Child items — makes this entry a submenu parent (one level). |
type | 'separator' | { type: 'separator' } renders a rule between groups. |
All methods return the instance (chainable).
| Method | Returns | Description |
|---|---|---|
open() | this | Open the menu (closes any other open root menu first). Equivalent to trigger click / ArrowDown / Enter / Space. |
close() | this | Close the menu; refocuses the trigger when focus was inside the panel. |
toggle() | this | Open if closed, close if open. |
update(items) | this | Swap the item list; re-renders (and restores focus) if the menu is open. |
destroy() | this | Close, remove the panel and listeners, restore trigger ARIA attributes, and unregister the instance. |
| Member | Returns | Description |
|---|---|---|
Menu.create(trigger, options?) | Menu | Constructor alias — same as new Menu(…). |
Menu.get(target) | Menu | null | The instance bound to a trigger (element or selector), or null. |
Menu.open(x, y, itemsOrOpts) | Menu | One-shot menu at viewport coordinates (pair with contextmenu: e.clientX / e.clientY). Accepts an items array or a full options object; focuses the first item so Esc/arrows/typeahead work immediately; destroys itself on close. |
Menu.closeAll() | Menu | Close the open root menu, if any. |
Menu.autoInit(root?) | Menu[] | Activate every [data-vmn] trigger under root (default: document) that has no instance yet. Runs automatically on load; call it for content added later. |
Menu.defaults | object | Global defaults — change any once. |
Menu.salt | string | false | CSS isolation namespace (default 'vc1'). Set your own token before the first menu opens, or false to disable. |
Menu.css | string | The full stylesheet, always rendered with the current salt — a starting point for headless styling. |
Menu.version | string | Library version. |
| Callback | Signature | Fires |
|---|---|---|
onOpen | fn(menu) | After the menu opens. |
onClose | fn(menu) | After the menu closes. |
item onSelect | fn(item, menu) | When that item is activated (click, Enter, or Space). |
Selecting an item also dispatches a bubbling menu:select CustomEvent on the trigger with { item, menu } in detail — the way to react to declarative menus:
trigger.addEventListener('menu:select', e => console.log(e.detail.item.label))
Menu.autoInit() runs on load and activates every [data-vmn] trigger. The attribute value is a selector to a <template> (or any element) holding the items; data-placement on the trigger overrides the default placement.
| Attribute / markup | Where | Meaning |
|---|---|---|
data-vmn="#file-menu" | trigger | Selector of the template/element holding the items; marks the trigger for auto-init. |
data-placement | trigger | Panel placement, e.g. bottom-end. |
<hr> | template child | Separator; every other child is an item. |
data-label | item child | Item label; falls back to the child’s own text (minus nested list markup). |
data-hint | item child | Right-aligned hint text. |
data-value | item child | Carried through to item.value in menu:select. |
data-danger | item child | Flag — destructive styling. |
data-disabled | item child | Flag — disabled item (a child’s own disabled property counts too). |
nested <ul> / <menu> | item child | Makes the entry a submenu of the list’s children (one level). |
<button data-vmn="#file-menu">File</button>
<template id="file-menu">
<button>New file</button>
<button data-hint="⌘S">Save</button>
<hr>
<li data-label="Share">
<ul>
<li>Copy link</li>
<li data-value="email">Email</li>
</ul>
</li>
<button data-danger>Delete</button>
</template>
Listen for menu:select on the trigger to react to selections.
Auto light/dark with the family’s resolution order: <html data-theme> / data-bs-theme / .dark class → prefers-color-scheme, re-resolved live. Pin it per menu with theme: 'dark' or family-wide via Menu.defaults.theme. All colors are CSS custom properties on .vmn (dark values on .vmn[data-theme=dark]):
| Property | Role |
|---|---|
--vmn-accent | Focus outline. |
--vmn-danger | Danger items. |
--vmn-bg | Panel background. |
--vmn-surface | Item hover/active fill. |
--vmn-text | Item text. |
--vmn-muted | Hints, icons, disabled items. |
--vmn-faint | Border and separators. |
--vmn-shadow | Panel shadow. |
--vmn-radius | Corner radius. |
--vmn-font | Font stack. |
CSS isolation — panels render as class="vmn vc1" and structural rules ship salted, so host-page design systems can’t override the menu; the --vmn-* variable overrides above keep working (var definitions are deliberately unsalted). Custom token: Menu.salt = 'acme' before the first menu opens; disable with Menu.salt = false.
Headless — Menu.defaults.styles = false (or per instance styles: false) injects no CSS, ever. You keep the full behavior (positioning, keyboard, typeahead, ARIA) and the markup contract: .vmn[data-theme="dark"].vmn-open root panel (.vmn-sub on flyouts) containing .vmn-item buttons (.vmn-icon, .vmn-label, .vmn-hint, .vmn-caret on submenu parents), .vmn-item.vmn-danger, .vmn-item[aria-disabled="true"], and .vmn-sep separators. Start from Menu.css (string) or dist/menu.css. With the VC core loaded, VC.config({ accent: … }) themes menus and every other family component in one call.
Full WAI-ARIA menu-button pattern: the trigger gets aria-haspopup="menu" + aria-expanded; the panel is role="menu" with role="menuitem" items, role="separator" rules, and aria-disabled on disabled items. Only one root menu is open at a time; outside clicks close it.
| Key | Action |
|---|---|
ArrowDown / Enter / Space | On the trigger: open, focus the first item. |
ArrowUp | On the trigger: open, focus the last item. |
ArrowDown / ArrowUp | Move focus; skips disabled items, wraps. |
Home / End | First / last enabled item. |
Enter / Space | Activate the focused item. |
ArrowRight | Open the focused item’s submenu, focus its first item. |
ArrowLeft | Close the submenu, refocus its parent item. |
Esc | Close and refocus the trigger. |
Tab | Close and move on. |
| Printable characters | Typeahead — jump to items by their first letters. |
:focus-visible outlines and prefers-reduced-motion are respected.
Pass styles: false (or set Menu.defaults.styles = false)
and no CSS is ever injected — you keep the full behavior (positioning, arrow keys, typeahead,
submenus, ARIA) and the stable .vmn-* markup contract to style however you like.
Both demos below run in an <iframe>: the styled examples above have already
injected the stylesheet into this page, so a headless menu out here would still match those rules.
The raw browser rendering — the panel is opened on load and is nothing but native buttons
in a positioned <div> (positioning is behavior, so it still lands under the
trigger). Keyboard still works: ArrowDown, typeahead, Esc.
new Menu('#m', {
styles: false, // headless: zero CSS injected
items: [
{ label: 'Rename' },
{ label: 'Duplicate', hint: '⌘D' },
{ type: 'separator' },
{ label: 'Delete', danger: true }
]
})
The same headless init with every structural .vmn-* hook mapped to Tailwind
utilities via @apply — items, icons, hints, danger/disabled states, separators, and
the submenu flyout. The button fetches cdn.tailwindcss.com, so nothing loads until
you ask.