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-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-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-raised 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-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 } 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">
<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>
<span class="text-xs text-success">Ready</span>
</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>
<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-raised shadow-sm transition"
:class="liveMode ? 'translate-x-4' : 'translate-x-0'"
></span>
</span>
</MenuItem>
<MenuItem value="notifications-compact" type="checkbox" :checked="compactSidebar">
<span class="min-w-0 truncate">Compact sidebar</span>
<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-raised shadow-sm transition"
:class="compactSidebar ? 'translate-x-4' : 'translate-x-0'"
></span>
</span>
</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>
<hr class="my-1 border-t border-border" />
<MenuItem value="delete" tone="danger">
<span class="min-w-0 truncate">Delete workspace</span>
<span class="text-xs text-destructive/70">Del</span>
</MenuItem>
</DomMenu>
<p class="text-xs text-muted-fg">Selected: <code class="text-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-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.