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.


Playground.vuevue
<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

Actions.vuevue
<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

NestedActions.vuevue
<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

RichItems.vuevue
<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

MenuItemMarkup.vuevue
<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

MultiSubmenus.vuevue
<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

NameTypeTSDefaultDescription
itemsarrayArray<unknown>Menu rows. Use separator: true for dividers.
orientation'vertical' | 'horizontal'string'vertical'Arrow key direction.
skinbooleanbooleantrueRender menu with the floating skin. Disable when embedding inside a popover or dropdown.

Auto-generated from Menu.props and inline _edit hints.

Events

NamePayloadDescription
@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.