Vanilla UI Kit Menu v1.0.0 · toast →

Menus, one file.

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>

Basic actions

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 }
  ]
})

Icons, hints, danger, disabled

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 }

Separators

{ type: 'separator' } groups related actions.

items: [
  { label: 'New file' },
  { label: 'New window' },
  { type: 'separator' },
  { label: 'Close' }
]

Submenu

One level of flyout — hover or ArrowRight to open, ArrowLeft to come back.

{ label: 'Share',
  items: [
    { label: 'Copy link' },
    { label: 'Email' },
    { label: 'Embed' }
  ] }

Context menu

Right-click (or long-press) the area — Menu.open(x, y, items) flips inward at viewport edges.

Right-click here
el.addEventListener('contextmenu', e => {
  e.preventDefault()
  Menu.open(e.clientX, e.clientY, items)
})

Declarative

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>

API reference

Constructor & usage

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>

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

Options

Second constructor argument; all keys of Menu.defaults. Change a default for every menu with, e.g., Menu.defaults.placement = 'bottom-end'.

NameTypeDefaultDescription
itemsarray[]The item list — see Items below.
placementstring'bottom-start'bottom/top + -start/-end; flips when out of room, clamps to the viewport.
closeOnSelectbooleantrueClose after an item is activated.
themestring'auto''auto' | 'light' | 'dark' (see Theming).
stylesbooleantruefalse = headless, no CSS ever injected (see Theming).
onOpenfunctionnullfn(menu) — see Events & callbacks.
onClosefunctionnullfn(menu) — see Events & callbacks.

Items

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):

PropertyTypeDescription
labelstringItem text — always rendered with textContent.
iconstringOptional; trusted inline SVG markup, like Toast’s icons.
hintstringOptional right-aligned kbd/hint text, e.g. '⌘D'.
dangerbooleanRed, for destructive actions.
disabledbooleanaria-disabled, skipped by the arrow keys.
onSelectfunctionfn(item, menu) when the item is activated.
itemsarrayChild items — makes this entry a submenu parent (one level).
type'separator'{ type: 'separator' } renders a rule between groups.

Methods

All methods return the instance (chainable).

MethodReturnsDescription
open()thisOpen the menu (closes any other open root menu first). Equivalent to trigger click / ArrowDown / Enter / Space.
close()thisClose the menu; refocuses the trigger when focus was inside the panel.
toggle()thisOpen if closed, close if open.
update(items)thisSwap the item list; re-renders (and restores focus) if the menu is open.
destroy()thisClose, remove the panel and listeners, restore trigger ARIA attributes, and unregister the instance.

Statics & helpers

MemberReturnsDescription
Menu.create(trigger, options?)MenuConstructor alias — same as new Menu(…).
Menu.get(target)Menu | nullThe instance bound to a trigger (element or selector), or null.
Menu.open(x, y, itemsOrOpts)MenuOne-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()MenuClose 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.defaultsobjectGlobal defaults — change any once.
Menu.saltstring | falseCSS isolation namespace (default 'vc1'). Set your own token before the first menu opens, or false to disable.
Menu.cssstringThe full stylesheet, always rendered with the current salt — a starting point for headless styling.
Menu.versionstringLibrary version.

Events & callbacks

CallbackSignatureFires
onOpenfn(menu)After the menu opens.
onClosefn(menu)After the menu closes.
item onSelectfn(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))

Declarative init

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 / markupWhereMeaning
data-vmn="#file-menu"triggerSelector of the template/element holding the items; marks the trigger for auto-init.
data-placementtriggerPanel placement, e.g. bottom-end.
<hr>template childSeparator; every other child is an item.
data-labelitem childItem label; falls back to the child’s own text (minus nested list markup).
data-hintitem childRight-aligned hint text.
data-valueitem childCarried through to item.value in menu:select.
data-dangeritem childFlag — destructive styling.
data-disableditem childFlag — disabled item (a child’s own disabled property counts too).
nested <ul> / <menu>item childMakes 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.

Theming

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]):

PropertyRole
--vmn-accentFocus outline.
--vmn-dangerDanger items.
--vmn-bgPanel background.
--vmn-surfaceItem hover/active fill.
--vmn-textItem text.
--vmn-mutedHints, icons, disabled items.
--vmn-faintBorder and separators.
--vmn-shadowPanel shadow.
--vmn-radiusCorner radius.
--vmn-fontFont 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.

Accessibility

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.

KeyAction
ArrowDown / Enter / SpaceOn the trigger: open, focus the first item.
ArrowUpOn the trigger: open, focus the last item.
ArrowDown / ArrowUpMove focus; skips disabled items, wraps.
Home / EndFirst / last enabled item.
Enter / SpaceActivate the focused item.
ArrowRightOpen the focused item’s submenu, focus its first item.
ArrowLeftClose the submenu, refocus its parent item.
EscClose and refocus the trigger.
TabClose and move on.
Printable charactersTypeahead — jump to items by their first letters.

:focus-visible outlines and prefers-reduced-motion are respected.

Headless mode

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.

Unstyled (styles: false)

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 }
  ]
})

Bring your own design (Tailwind)

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.