Component
Menu
<dom-menu>A roving-focus action menu for command lists, settings panels, and skinned menus.
Playground
Try every prop live
Menu playground
Edit menu rows in the inspector and use arrow keys to move between items.
<script setup>
import { reactive } from 'vue';
import { DomMenu } from '@getdom/studio/vue';
const data = reactive({
"items": [
{
"value": "rename",
"label": "Rename",
"separator": false,
"tone": ""
},
{
"value": "duplicate",
"label": "Duplicate",
"separator": false,
"tone": ""
},
{
"separator": true,
"value": "",
"label": "",
"tone": ""
},
{
"value": "delete",
"label": "Delete",
"tone": "danger",
"separator": false
}
],
"orientation": "vertical",
"skin": true
});
</script>
<template>
<DomMenu
v-bind="data"
/>
</template>Demo
Actions
Use menuitem, menuitemcheckbox, separators, and destructive tone in one keyboard navigable menu.
Selected: none
<script setup>
import { ref } from 'vue';
import { DomMenu } from '@getdom/studio/vue';
const selected = ref('none');
const items = [
{ value: 'rename', label: 'Rename' },
{ value: 'duplicate', label: 'Duplicate' },
{ separator: true },
{ value: 'notifications', label: 'Notifications', type: 'checkbox', checked: true },
{ value: 'archive', label: 'Archive' },
{ value: 'delete', label: 'Delete', tone: 'danger' },
];
</script>
<template>
<div class="grid w-full max-w-sm gap-3">
<DomMenu :items="items" @select="selected = $event.value" />
<p class="text-xs text-muted-fg">Selected: <code class="text-canvas-fg">{{ selected }}</code></p>
</div>
</template>
Demo
Nested menu
Menu items can include children. Hover, ArrowRight, Enter, or Space opens the submenu; ArrowLeft or Esc returns to the parent.
Selected: none
<script setup>
import { ref } from 'vue';
import { DomButton, DomMenu, DomPopover } from '@getdom/studio/vue';
const popover = ref(null);
const menu = ref(null);
const selected = ref('none');
const items = [
{ label: 'Open', value: 'open' },
{ label: 'Rename', value: 'rename' },
{ label: 'Duplicate', value: 'duplicate' },
{ label: 'Delete', value: 'delete', tone: 'danger' },
{
label: 'Share',
value: 'share',
children: [
{ label: 'Email', value: 'share-email' },
{ label: 'SMS', value: 'share-sms' },
{ label: 'Instagram', value: 'share-instagram' },
],
},
];
function onSelect(event) {
selected.value = event.value;
popover.value?.close();
}
function focusMenu() {
requestAnimationFrame(() => {
menu.value?.querySelector('[role="menuitem"]')?.focus();
});
}
</script>
<template>
<div class="space-y-3">
<DomPopover ref="popover" position="bottom-end" width="min-w-48" padding="p-1" :arrow="false" @open="focusMenu">
<template #trigger>
<DomButton variant="secondary" size="sm" aria-label="Open actions">
<span aria-hidden="true" class="text-lg leading-none">...</span>
</DomButton>
</template>
<div ref="menu">
<DomMenu :items="items" :skin="false" @select="onSelect" />
</div>
</DomPopover>
<p class="text-xs text-muted-fg">Selected: <code class="text-canvas-fg">{{ selected }}</code></p>
</div>
</template>
Demo
Icon menu
Use the item-row slot to add leading icons, shortcuts, disabled rows, and icon-bearing submenus while keeping DomMenu keyboard behaviour.
Selected: none
<script setup>
import { ref } from 'vue';
import { DomMenu } from '@getdom/studio/vue';
const selected = ref('none');
const icons = {
minimise: [
'M7 12h10',
'M5 5h14v14H5z',
],
zoom: [
'M8 3H5a2 2 0 0 0-2 2v3',
'M16 3h3a2 2 0 0 1 2 2v3',
'M8 21H5a2 2 0 0 1-2-2v-3',
'M16 21h3a2 2 0 0 0 2-2v-3',
],
fill: [
'M5 7h14v10H5z',
'M7 9h10v6H7z',
],
centre: [
'M6 8h12v8H6z',
'M9 11h6v2H9z',
],
move: [
'M6 8h7v8H6z',
'M15 9l3 3-3 3',
'M18 12H9',
],
tile: [
'M5 6h14v12H5z',
'M12 6v12',
],
remove: [
'M5 8h12v8H5z',
'M17 6h2v2',
'M4 17l3-3',
'M4 14h3v3',
],
window: [
'M5 6h14v12H5z',
'M5 10h14',
],
stack: [
'M8 6l4-2 4 2-4 2-4-2z',
'M6 10l6 3 6-3',
'M6 14l6 3 6-3',
],
left: [
'M5 6h14v12H5z',
'M12 6v12',
'M7 8h3v8H7z',
],
right: [
'M5 6h14v12H5z',
'M12 6v12',
'M14 8h3v8h-3z',
],
top: [
'M5 6h14v12H5z',
'M5 12h14',
'M7 8h10v2H7z',
],
bottom: [
'M5 6h14v12H5z',
'M5 12h14',
'M7 14h10v2H7z',
],
};
const items = [
{ value: 'minimise', label: 'Minimise', icon: 'minimise', shortcut: 'Cmd M' },
{ value: 'zoom', label: 'Zoom', icon: 'zoom' },
{ value: 'fill', label: 'Fill', icon: 'fill', shortcut: 'Ctrl Cmd F' },
{ value: 'centre', label: 'Centre', icon: 'centre', shortcut: 'Ctrl Cmd C' },
{ separator: true },
{
value: 'move-resize',
label: 'Move & Resize',
icon: 'move',
children: [
{ value: 'move-left', label: 'Left', icon: 'left', shortcut: 'Ctrl Cmd Left' },
{ value: 'move-right', label: 'Right', icon: 'right', shortcut: 'Ctrl Cmd Right' },
{ value: 'move-top', label: 'Top', icon: 'top', shortcut: 'Ctrl Cmd Up' },
{ value: 'move-bottom', label: 'Bottom', icon: 'bottom', shortcut: 'Ctrl Cmd Down' },
],
},
{
value: 'full-screen-tile',
label: 'Full-Screen Tile',
icon: 'tile',
children: [
{ value: 'tile-left', label: 'Left & Right', icon: 'left' },
{ value: 'tile-right', label: 'Right & Left', icon: 'right' },
{ value: 'tile-top', label: 'Top & Bottom', icon: 'top' },
{ value: 'tile-bottom', label: 'Bottom & Top', icon: 'bottom' },
],
},
{ separator: true },
{ value: 'remove-window', label: 'Remove Window from Set', icon: 'remove', disabled: true },
{ separator: true },
{ value: 'name-window', label: 'Name Window...', icon: 'window' },
{ value: 'bring-front', label: 'Bring All to Front', icon: 'stack' },
];
</script>
<template>
<div class="grid w-full max-w-md gap-3">
<DomMenu :items="items" @select="selected = $event.value">
<template #item-row="{ item, label, hasChildren }">
<span class="flex min-w-0 items-center gap-3">
<span class="grid size-5 shrink-0 place-items-center text-muted-fg">
<svg
v-if="item.icon && icons[item.icon]"
viewBox="0 0 24 24"
class="size-4"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path v-for="path in icons[item.icon]" :key="path" :d="path" />
</svg>
</span>
<span class="min-w-0 truncate">{{ label }}</span>
</span>
<span class="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-fg">
<span v-if="item.shortcut">{{ item.shortcut }}</span>
<span v-if="hasChildren" aria-hidden="true">›</span>
</span>
</template>
</DomMenu>
<p class="text-xs text-muted-fg">Selected: <code class="text-canvas-fg">{{ selected }}</code></p>
</div>
</template>
Demo
Rich item markup
Set item.slot to render per-item markup such as status rows, live badges, shortcuts, and switch-style checkbox items while DomMenu keeps keyboard and selection behaviour.
Selected: none
<script setup>
import { ref } from 'vue';
import { DomMenu } from '@getdom/studio/vue';
const selected = ref('none');
const items = ref([
{ label: 'Sync status', value: 'sync', slot: 'sync' },
{ label: 'Live mode', value: 'live', slot: 'live', badge: 'Live' },
{ separator: true },
{ label: 'Notifications', value: 'notifications', type: 'checkbox', checked: true, slot: 'switch' },
{ label: 'Compact sidebar', value: 'compact-sidebar', type: 'checkbox', checked: false, slot: 'switch' },
{ separator: true },
{ label: 'Delete workspace', value: 'delete', tone: 'danger', slot: 'danger' },
]);
function onSelect(event) {
selected.value = event.value;
}
function onChange(event) {
const item = items.value.find((candidate) => candidate.value === event.value);
if (item) item.checked = event.checked;
}
</script>
<template>
<div class="w-full max-w-sm space-y-3">
<DomMenu :items="items" @select="onSelect" @change="onChange">
<template #sync="{ label }">
<span class="flex min-w-0 items-center gap-3">
<span class="grid size-8 place-items-center rounded-lg bg-success/15 text-success">
<svg viewBox="0 0 24 24" class="size-4" fill="none" aria-hidden="true">
<path d="M6.5 8.5A6 6 0 0 1 17 7l1 .95M17.5 15.5A6 6 0 0 1 7 17l-1-.95M18 4.5V8h-3.5M6 19.5V16h3.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<span class="min-w-0">
<span class="block truncate font-medium">{{ label }}</span>
<span class="block truncate text-xs text-muted-fg">Updated just now</span>
</span>
</span>
<span class="text-xs text-success">Ready</span>
</template>
<template #live="{ label, item }">
<span class="min-w-0">
<span class="block truncate font-medium">{{ label }}</span>
<span class="block truncate text-xs text-muted-fg">Broadcast changes to viewers</span>
</span>
<span class="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-medium text-success">{{ item.badge }}</span>
</template>
<template #switch="{ label, checked }">
<span class="min-w-0 truncate">{{ label }}</span>
<span
class="relative inline-flex h-5 w-9 shrink-0 rounded-full transition"
:class="checked ? 'bg-primary' : 'bg-input'"
aria-hidden="true"
>
<span
class="absolute left-0.5 top-0.5 size-4 rounded-full skin-card shadow-sm transition"
:class="checked ? 'translate-x-4' : 'translate-x-0'"
></span>
</span>
</template>
<template #danger="{ label }">
<span class="min-w-0 truncate">{{ label }}</span>
<span class="text-xs text-destructive/70">Del</span>
</template>
</DomMenu>
<p class="text-xs text-muted-fg">Selected: <code class="text-canvas-fg">{{ selected }}</code></p>
</div>
</template>
Demo
MenuItem markup
Use <MenuItem> rows when custom markup should live directly inside the menu instead of going through item slots. The same markup can appear inside a submenu.
Selected: none
<script setup>
import { ref } from 'vue';
import { DomMenu, MenuItem, MenuLabel, MenuSeparator } from '@getdom/studio/vue';
const selected = ref('none');
const liveMode = ref(true);
const compactSidebar = ref(false);
function onSelect(event) {
selected.value = event.value;
}
function onChange(event) {
if (event.value === 'notifications-live') liveMode.value = event.checked;
if (event.value === 'notifications-compact') compactSidebar.value = event.checked;
}
</script>
<template>
<div class="w-full max-w-sm space-y-3">
<DomMenu @select="onSelect" @change="onChange">
<MenuLabel>
<span class="block text-[11px] font-medium uppercase text-muted-fg">Workspace</span>
<span class="mt-0.5 block truncate text-sm font-medium text-canvas-fg">Northstar Studio</span>
</MenuLabel>
<MenuSeparator />
<MenuItem value="sync">
<span class="flex min-w-0 items-center gap-3">
<span class="grid size-8 place-items-center rounded-lg bg-success/15 text-success">
<svg viewBox="0 0 24 24" class="size-4" fill="none" aria-hidden="true">
<path d="M6.5 8.5A6 6 0 0 1 17 7l1 .95M17.5 15.5A6 6 0 0 1 7 17l-1-.95M18 4.5V8h-3.5M6 19.5V16h3.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<span class="min-w-0">
<span class="block truncate font-medium">Sync status</span>
<span class="block truncate text-xs text-muted-fg">Updated just now</span>
</span>
</span>
<template #accessory>
<span class="text-xs text-success">Ready</span>
</template>
</MenuItem>
<MenuItem value="notifications">
<span class="min-w-0">
<span class="block truncate font-medium">Notifications</span>
<span class="block truncate text-xs text-muted-fg">Delivery channels and density</span>
</span>
<template #submenu>
<MenuItem value="notifications-live" type="checkbox" :checked="liveMode">
<span class="min-w-0 truncate">Live mode</span>
<template #accessory>
<span class="relative inline-flex h-5 w-9 shrink-0 rounded-full transition" :class="liveMode ? 'bg-primary' : 'bg-input'" aria-hidden="true">
<span class="absolute left-0.5 top-0.5 size-4 rounded-full skin-card shadow-sm transition" :class="liveMode ? 'translate-x-4' : 'translate-x-0'"></span>
</span>
</template>
</MenuItem>
<MenuItem value="notifications-compact" type="checkbox" :checked="compactSidebar">
<span class="min-w-0 truncate">Compact sidebar</span>
<template #accessory>
<span class="relative inline-flex h-5 w-9 shrink-0 rounded-full transition" :class="compactSidebar ? 'bg-primary' : 'bg-input'" aria-hidden="true">
<span class="absolute left-0.5 top-0.5 size-4 rounded-full skin-card shadow-sm transition" :class="compactSidebar ? 'translate-x-4' : 'translate-x-0'"></span>
</span>
</template>
</MenuItem>
<MenuItem value="notifications-email">
<span class="min-w-0">
<span class="block truncate font-medium">Email digest</span>
<span class="block truncate text-xs text-muted-fg">Send once each morning</span>
</span>
</MenuItem>
</template>
</MenuItem>
<MenuSeparator />
<MenuItem value="delete" tone="danger">
<span class="min-w-0 truncate">Delete workspace</span>
<template #accessory>
<span class="text-xs text-destructive/70">Del</span>
</template>
</MenuItem>
</DomMenu>
<p class="text-xs text-muted-fg">Selected: <code class="text-canvas-fg">{{ selected }}</code></p>
</div>
</template>
Demo
Multiple submenus
Several rows can own submenus, and nested submenu rows can open their own submenu to the right.
Selected: none
<script setup>
import { ref } from 'vue';
import { DomMenu } from '@getdom/studio/vue';
const selected = ref('none');
const items = [
{ label: 'Open', value: 'open' },
{
label: 'Insert',
value: 'insert',
children: [
{ label: 'Text block', value: 'insert-text' },
{ label: 'Image', value: 'insert-image' },
{
label: 'Chart',
value: 'insert-chart',
children: [
{ label: 'Bar chart', value: 'insert-chart-bar' },
{ label: 'Line chart', value: 'insert-chart-line' },
{ label: 'Donut chart', value: 'insert-chart-donut' },
],
},
],
},
{
label: 'Share',
value: 'share',
children: [
{ label: 'Copy link', value: 'share-link' },
{ label: 'Invite people', value: 'share-invite' },
{ label: 'Publish snapshot', value: 'share-publish' },
],
},
{
label: 'Transform',
value: 'transform',
children: [
{ label: 'Make uppercase', value: 'transform-uppercase' },
{ label: 'Sort ascending', value: 'transform-sort-asc' },
{ label: 'Sort descending', value: 'transform-sort-desc' },
],
},
{
label: 'Export',
value: 'export',
children: [
{ label: 'PDF', value: 'export-pdf' },
{ label: 'CSV', value: 'export-csv' },
{ label: 'JSON', value: 'export-json' },
],
},
];
</script>
<template>
<div class="grid w-full max-w-sm gap-3">
<DomMenu :items="items" @select="selected = $event.value" />
<p class="text-xs text-muted-fg">Selected: <code class="text-canvas-fg">{{ selected }}</code></p>
</div>
</template>
Reference
Props
Control props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
items | array | Array<unknown> | — | Menu rows. Use separator: true for dividers. |
orientation | 'vertical' | 'horizontal' | string | 'vertical' | Arrow key direction. |
skin | boolean | boolean | true | Render menu with the floating skin. Disable when embedding inside a popover or dropdown. |
Auto-generated from Menu.props and inline _edit hints.
Events
| Name | Payload | Description |
|---|---|---|
| @select | ({ value, item }) | Fired when an item is selected. |
| @change | ({ value, checked, item }) | Fired when a checkbox or radio item changes. |
Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.