packages/frontend/@n8n/design-system/src/v2/components/DropdownMenu/component-dropdown-menu.md
A flexible dropdown menu component that provides contextual actions and options. It combines the functionality of ActionDropdown and ActionToggle, offering a unified interface for dropdown menus across n8n.
It's built on Reka UI's DropdownMenu for accessibility and interaction patterns.
id?: string Unique identifier for the dropdownitems: Array<DropdownMenuItem<T>> Array of menu items to displaymodelValue?: boolean The controlled open state of the dropdown. Can be bind as v-modeldefaultOpen?: boolean The open state of the dropdown when initially renderedplacement?: 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end' Dropdown placement relative to trigger | default: 'bottom'trigger?: 'click' | 'hover' How the dropdown is triggered | default: 'click'activatorIcon?: IconOrEmoji Icon or emoji for the default trigger button | default: { type: 'icon', value: 'ellipsis' }disabled?: boolean When true, prevents the user from interacting with dropdownteleported?: boolean Whether to teleport the dropdown to body | default: truemaxHeight?: string | number Maximum height of the dropdown menuloading?: boolean Whether to show loading stateloadingItemCount?: number Number of skeleton items to show when loading | default: 3extraPopperClass?: string Additional CSS class for the dropdown popperSearch-specific Props
searchable?: boolean Enable search functionalitysearchPlaceholder?: string Search input placeholdersearchDebounce?: number Debounce delay in ms | default: 300Events
update:modelValue(open: boolean) Emitted when dropdown open state changesselect(value: T) Emitted when a menu item is selectedsearch(searchTerm: string, itemId?: T) Emitted when search input changes (debounced). itemId is undefined for root-level search, or the item's ID for sub-menu searchsubmenu:toggle(itemId: T, open: boolean) Emitted when a sub-menu opens or closes. Useful for lazy loading sub-menu contentExposed Methods
open() Programmatically opens the dropdownclose() Programmatically closes the dropdownSlots
trigger Custom trigger element (replaces default button)content Complete custom dropdown content (replaces item list)item Custom item rendering (replaces default N8nDropdownMenuItem) { item: DropdownMenuItemProps<T> }item-leading Pass-through to N8nDropdownMenuItem { item: DropdownMenuItemProps<T>, ui: { class: string } }item-trailing Pass-through to N8nDropdownMenuItem { item: DropdownMenuItemProps<T>, ui: { class: string } }loading Custom loading stateempty Custom empty state when no itemsTypes
type IconOrEmoji =
| { type: 'icon'; value: IconName }
| { type: 'emoji'; value: string };
type DropdownMenuItemProps<T = string> = {
id: T;
label: string;
icon?: IconOrEmoji;
disabled?: boolean;
divided?: boolean; // Shows separator above item
checked?: boolean; // Shows checkmark
class?: string | Record<string, boolean> | Array<string>;
// Sub-menu support
children?: Array<DropdownMenuItemProps<T>>;
loading?: boolean;
loadingItemCount?: number;
searchable?: boolean;
searchPlaceholder?: string;
}
A companion component for rendering individual dropdown items with full slot-based customization.
Props
id: T Unique identifier for the itemlabel?: string Display text for the itemicon?: IconOrEmoji Icon or emoji displayed before the labeldisabled?: boolean Whether the item is disableddivided?: boolean Whether to show a separator above the item (from ActionDropdownItem)checked?: boolean Whether to show a checkmark indicator (from ActionDropdownItem)class?: string Additional CSS classeschildren?: Array<DropdownMenuItemProps<T>> Nested menu items (creates a sub-menu)loading?: boolean Whether to show loading state in sub-menuloadingItemCount?: number Number of skeleton items when loading | default: 3searchable?: boolean Enable search functionality for this item's childrensearchPlaceholder?: string Search input placeholderEvents
select(value: T) Emitted when item or child is selectedsearch(searchTerm: string, itemId: T) Emitted when sub-menu search changesupdate:subMenuOpen(open: boolean) Emitted when sub-menu open state changesSlots
item-leading Content before the label (default: icon if provided) { item: DropdownMenuItemProps, ui: { class: string } }item-trailing Content after the label (badges, shortcuts, etc.) { item: DropdownMenuItemProps, ui: { class: string } }Basic example with items
<script setup lang="ts">
const dropdownItems = ref([
{ id: 'edit', label: 'Edit', icon: { type: 'icon', value: 'pen' } },
{ id: 'duplicate', label: 'Duplicate', icon: { type: 'icon', value: 'copy' } },
{ id: 'delete', label: 'Delete', icon: { type: 'icon', value: 'trash' }, divided: true }
])
const handleSelect = (action: string) => {
console.log('Selected:', action)
}
</script>
<template>
<N8nDropdownMenu
:items="dropdownItems"
@select="handleSelect"
/>
</template>
With custom trigger
<script setup lang="ts">
const isOpen = ref(false)
const items = ref([
{ id: 'profile', label: 'Profile', icon: { type: 'icon', value: 'user' } },
{ id: 'settings', label: 'Settings', icon: { type: 'icon', value: 'cog' } },
{ id: 'logout', label: 'Sign out', icon: { type: 'icon', value: 'sign-out' }, divided: true }
])
</script>
<template>
<N8nDropdownMenu
v-model="isOpen"
:items="items"
placement="bottom-end"
>
<template #trigger>
<N8nButton variant="secondary" icon="user">
Account
</N8nButton>
</template>
</N8nDropdownMenu>
</template>
With emoji activator
<script setup lang="ts">
const items = ref([
{ id: 'option-1', label: 'Option 1' },
{ id: 'option-2', label: 'Option 2' }
])
</script>
<template>
<N8nDropdownMenu
:items="items"
:activator-icon="{ type: 'emoji', value: '🎉' }"
@select="handleSelect"
/>
</template>
With badges and shortcuts using slots
<script setup lang="ts">
const items = ref([
{ id: 'save', label: 'Save', icon: { type: 'icon', value: 'save' } },
{ id: 'share', label: 'Share', icon: { type: 'icon', value: 'share' } },
{ id: 'pro', label: 'Pro Feature', icon: { type: 'icon', value: 'star' }, disabled: true }
])
const handleUpgrade = () => {
// Show upgrade modal
}
</script>
<template>
<N8nDropdownMenu
:items="items"
:activator-icon="{ type: 'icon', value: 'ellipsis' }"
@select="handleSelect"
>
<template #item-trailing="{ item, ui }">
<N8nKeyboardShortcut
v-if="item.id === 'save'"
:keys="['S']"
modifier="cmd"
:class="ui.class"
/>
<N8nBadge
v-if="item.id === 'share'"
theme="success"
bold
:class="ui.class"
>
New
</N8nBadge>
<N8nBadge
v-if="item.id === 'pro'"
theme="primary"
:class="ui.class"
@click.stop="handleUpgrade"
>
PRO
</N8nBadge>
</template>
</N8nDropdownMenu>
</template>
Sub-menu example
<script setup lang="ts">
const items = ref([
{ id: 'file', label: 'File', icon: { type: 'icon', value: 'file' } },
{
id: 'export',
label: 'Export as...',
icon: { type: 'icon', value: 'download' },
children: [
{ id: 'pdf', label: 'PDF' },
{ id: 'csv', label: 'CSV' },
{ id: 'json', label: 'JSON' }
]
},
])
</script>
<template>
<N8nDropdownMenu :items="items" @select="handleSelect" />
</template>
Searchable sub-menu (async search)
<script setup lang="ts">
const users = ref([])
const loading = ref(false)
const items = computed(() => [
{
id: 'select-user',
label: 'Assign to...',
searchable: true,
searchPlaceholder: 'Search users...',
loading: loading.value,
children: users.value
},
])
const handleSearch = async (term: string, itemId?: string) => {
if (itemId === 'select-user') {
loading.value = true
users.value = await fetchUsers(term)
loading.value = false
}
}
</script>
<template>
<N8nDropdownMenu
:items="items"
@select="handleSelect"
@search="handleSearch"
/>
</template>
Loading state
<script setup lang="ts">
const loading = ref(true)
const items = ref([])
onMounted(async () => {
// Fetch items
items.value = await fetchMenuItems()
loading.value = false
})
</script>
<template>
<N8nDropdownMenu
:items="items"
:loading="loading"
:loading-item-count="5"
/>
</template>
Lazy loading sub-menu items
<script setup lang="ts">
const recentFilesLoading = ref(true)
const recentFiles = ref([])
const items = computed(() => [
{ id: 'new', label: 'New File', icon: { type: 'icon', value: 'plus' } },
{
id: 'recent',
label: 'Recent Files',
icon: { type: 'icon', value: 'clock' },
loading: recentFilesLoading.value,
loadingItemCount: 4,
children: recentFiles.value
},
{ id: 'settings', label: 'Settings', icon: { type: 'icon', value: 'cog' } }
])
const handleSubmenuToggle = async (itemId: string, open: boolean) => {
// Only fetch when sub-menu opens and data hasn't been loaded
if (itemId === 'recent' && open && recentFilesLoading.value) {
recentFiles.value = await fetchRecentFiles()
recentFilesLoading.value = false
}
}
// Reset on dropdown close to show loading again next time
const handleOpenChange = (open: boolean) => {
if (!open) {
recentFilesLoading.value = true
recentFiles.value = []
}
}
</script>
<template>
<N8nDropdownMenu
:items="items"
@select="handleSelect"
@update:model-value="handleOpenChange"
@submenu:toggle="handleSubmenuToggle"
/>
</template>
Using N8nDropdownMenuItem for full control
<script setup lang="ts">
const items = ref([
{ id: 'option-1', label: 'Option 1' },
{ id: 'option-2', label: 'Option 2' }
])
</script>
<template>
<N8nDropdownMenu :items="items" @select="handleSelect">
<template #item="{ item }">
<N8nDropdownMenuItem v-bind="item">
<template #item-leading="{ ui }">
<MyCustomIcon :class="ui.class" />
</template>
<template #item-trailing="{ ui }">
<N8nBadge :class="ui.class">Custom</N8nBadge>
</template>
</N8nDropdownMenuItem>
</template>
</N8nDropdownMenu>
</template>
<!-- Before -->
<N8nActionDropdown
:items="items"
placement="bottom-start"
:activator-icon="icon"
@select="onSelect"
@badge-click="onBadgeClick"
/>
<!-- After -->
<N8nDropdownMenu
:items="items"
placement="bottom-start"
:activator-icon="{ type: 'icon', value: icon }"
@select="onSelect"
>
<!-- Badge functionality now handled via slots -->
<template #item-trailing="{ item, ui }">
<N8nBadge
v-if="item.badge"
:class="ui.class"
@click.stop="onBadgeClick(item.id)"
>
{{ item.badge }}
</N8nBadge>
</template>
</N8nDropdownMenu>
<!-- Before -->
<N8nActionToggle
:actions="actions"
placement="bottom"
:loading="loading"
:loading-row-count="3"
@action="onAction"
/>
<!-- After -->
<N8nDropdownMenu
:items="actions"
placement="bottom"
:loading="loading"
:loading-item-count="3"
@select="onAction"
/>
closeOnParentScroll is not yet implemented - requires useParentScroll composable migrationupdate:modelValue replaces both visible-change and visibleChange eventssearchable prop. Both root-level and sub-menu search are supportedsearch event to filter items externally (e.g., for async search)icon prop now accepts IconOrEmoji type: { type: 'icon', value: 'pen' } or { type: 'emoji', value: '🎉' }