Dropdown
A flexible menu component for actions. Handles groups, nested submenus, toggle rows, disabled items, custom triggers, and a built-in kebab pattern.
Playground
Simple
A plain actions menu with icons. The default trigger is an auto-generated <Button> — pass button: { label } to override its text.
<script setup lang="ts">
import { ref } from 'vue'
import { Dropdown, type DropdownOptions } from 'frappe-ui'
const lastAction = ref('')
const actions: DropdownOptions = [
{
label: 'Edit',
icon: 'lucide-pen',
onClick: () => (lastAction.value = 'Edited'),
},
{
label: 'Duplicate',
icon: 'lucide-copy',
onClick: () => (lastAction.value = 'Duplicated'),
},
{
label: 'Delete',
icon: 'lucide-trash-2',
theme: 'red',
onClick: () => (lastAction.value = 'Deleted'),
},
]
</script>
<template>
<div class="grid gap-3 justify-items-center">
<Dropdown
:options="actions"
:button="{ icon: 'lucide-more-horizontal', label: 'Options' }"
/>
<div class="text-sm text-ink-gray-5">
{{ lastAction || 'Pick an action' }}
</div>
</div>
</template>With Shortcuts
Keyboard shortcuts rendered in the row suffix. Use the #item-suffix slot and a custom shortcut field on each item to keep the label clean and the hint secondary.
<script setup lang="ts">
import { Dropdown } from 'frappe-ui'
type FileAction = {
label: string
icon: string
shortcut: string[]
theme?: 'red'
onClick?: () => void
}
const actions: FileAction[] = [
{ label: 'New document', icon: 'lucide-plus', shortcut: ['⌘', 'N'] },
{ label: 'Rename', icon: 'lucide-pen', shortcut: ['F2'] },
{ label: 'Duplicate', icon: 'lucide-copy', shortcut: ['⌘', 'D'] },
{
label: 'Delete',
icon: 'lucide-trash-2',
shortcut: ['⌫'],
theme: 'red',
},
]
</script>
<template>
<Dropdown :options="actions" :button="{ label: 'File' }">
<template #item-suffix="{ item }">
<div
v-if="(item as FileAction).shortcut"
class="flex items-center gap-px"
>
<kbd
v-for="(key, i) in (item as FileAction).shortcut"
:key="i"
class="font-sans text-xs leading-4 text-ink-gray-6"
>
{{ key }}
</kbd>
</div>
</template>
</Dropdown>
</template>Submenus
Grouped actions with nested submenus — the "Share" path recurses into "Invite people" which recurses into channel targets. Groups are just { group, options } entries in the options array.
<script setup lang="ts">
import { Dropdown, type DropdownOptions } from 'frappe-ui'
const actions: DropdownOptions = [
{
group: 'Manage',
options: [
{
label: 'Share',
icon: 'lucide-share-2',
submenu: [
{
label: 'Copy link',
icon: 'lucide-link',
onClick: () => console.log('copy link'),
},
{
label: 'Invite people',
icon: 'lucide-user-plus',
submenu: [
{
label: 'By email',
icon: 'lucide-mail',
onClick: () => console.log('invite by email'),
},
{
label: 'Send to Slack',
icon: 'lucide-message-circle',
onClick: () => console.log('send to slack'),
},
],
},
],
},
{
label: 'Move to',
icon: 'lucide-folder-input',
submenu: [
{
label: 'Backlog',
icon: 'lucide-inbox',
onClick: () => console.log('move to backlog'),
},
{
label: 'Done',
icon: 'lucide-check-circle',
onClick: () => console.log('move to done'),
},
],
},
],
},
{
group: 'Danger',
options: [
{
label: 'Delete',
icon: 'lucide-trash-2',
theme: 'red',
onClick: () => console.log('delete'),
},
],
},
]
</script>
<template>
<Dropdown :options="actions" :button="{ label: 'Actions' }" />
</template>Switches
Toggle items live inside the menu using switch: true + switchValue. Clicking a switch row fires onClick(boolean) with the new value and keeps the menu open, so consumers can flip multiple settings in one sitting.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Dropdown } from 'frappe-ui'
const notifications = ref(true)
const readReceipts = ref(true)
const focusMode = ref(false)
const autoSave = ref(true)
const preferences = computed(() => [
{
label: 'Notifications',
icon: 'lucide-bell',
switch: true,
switchValue: notifications.value,
onClick: (value: boolean) => (notifications.value = value),
},
{
label: 'Read receipts',
icon: 'lucide-eye',
switch: true,
switchValue: readReceipts.value,
onClick: (value: boolean) => (readReceipts.value = value),
},
{
label: 'Focus mode',
icon: 'lucide-zap',
switch: true,
switchValue: focusMode.value,
onClick: (value: boolean) => (focusMode.value = value),
},
{
label: 'Auto-save drafts',
icon: 'lucide-save',
switch: true,
switchValue: autoSave.value,
onClick: (value: boolean) => (autoSave.value = value),
},
])
</script>
<template>
<Dropdown :options="preferences" :button="{ label: 'Preferences' }" />
</template>Kebab Menu
The classic row-actions pattern — a ghost icon button that opens a grouped menu. #trigger swaps in the LucideMoreHorizontal button, and the open slot prop keeps the button in its active state while the menu is open.
<script setup lang="ts">
import { Button, Dropdown, type DropdownOptions } from 'frappe-ui'
type Task = { title: string; meta: string }
const tasks: Task[] = [
{ title: 'Ship Combobox v1', meta: 'Due Friday · Engineering' },
{ title: 'Write migration guide', meta: 'In review · Design' },
{ title: 'Audit selection components', meta: 'Backlog · Platform' },
]
function rowActions(task: Task): DropdownOptions {
return [
{
group: 'Manage',
options: [
{
label: 'Rename',
icon: 'lucide-pen',
onClick: () => console.log('rename', task.title),
},
{
label: 'Duplicate',
icon: 'lucide-copy',
onClick: () => console.log('duplicate', task.title),
},
{
label: 'Share',
icon: 'lucide-share-2',
onClick: () => console.log('share', task.title),
},
],
},
{
group: 'Move',
options: [
{
label: 'Archive',
icon: 'lucide-archive',
onClick: () => console.log('archive', task.title),
},
{
label: 'Delete',
icon: 'lucide-trash-2',
theme: 'red',
onClick: () => console.log('delete', task.title),
},
],
},
]
}
</script>
<template>
<div
class="w-80 divide-y divide-outline-gray-1 rounded-lg border border-outline-gray-1 bg-surface-base"
>
<div
v-for="task in tasks"
:key="task.title"
class="flex items-center gap-3 px-3 py-2.5"
>
<div class="min-w-0 flex-1">
<div class="truncate text-base text-ink-gray-8">{{ task.title }}</div>
<div class="truncate text-p-sm text-ink-gray-5">{{ task.meta }}</div>
</div>
<Dropdown align="end" :options="rowActions(task)">
<template #trigger="{ open }">
<Button
variant="ghost"
icon="lucide-more-horizontal"
:active="open"
aria-label="Row actions"
/>
</template>
</Dropdown>
</div>
</div>
</template>User Menu
A real workspace + profile menu — nested Apps / Theme submenus, account-group footer, and a completely custom trigger showing the workspace icon, product name, and current user.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Dropdown } from 'frappe-ui'
const theme = ref<'light' | 'dark' | 'system'>('system')
const userMenuItems = computed(() => [
{
icon: 'lucide-user',
label: 'My Profile',
onClick: () => console.log('My Profile clicked'),
},
{
icon: 'lucide-layout-grid',
label: 'Apps',
submenu: [
{
label: 'Gameplan',
selected: true,
onClick: () => console.log('Gameplan clicked'),
},
{
label: 'Drive',
onClick: () => console.log('Drive clicked'),
},
{
label: 'Helpdesk',
onClick: () => console.log('Helpdesk clicked'),
},
],
},
{
icon: 'lucide-settings',
label: 'Settings & Members',
onClick: () => console.log('Settings & Members clicked'),
},
{
icon: 'lucide-moon',
label: 'Theme',
submenu: [
{
label: 'Light Mode',
selected: theme.value === 'light',
onClick: () => {
theme.value = 'light'
},
},
{
label: 'Dark Mode',
selected: theme.value === 'dark',
onClick: () => {
theme.value = 'dark'
},
},
{
label: 'System Default',
selected: theme.value === 'system',
onClick: () => {
theme.value = 'system'
},
},
],
},
{
group: 'Account',
options: [
{
icon: 'lucide-list-restart',
label: 'Clear cache',
onClick: () => console.log('Clear cache clicked'),
},
{
icon: 'lucide-info',
label: 'About',
onClick: () => console.log('About clicked'),
},
{
icon: 'lucide-log-out',
label: 'Log out',
onClick: () => console.log('Log out clicked'),
},
],
},
])
</script>
<template>
<div class="flex flex-col items-center gap-4">
<Dropdown :options="userMenuItems">
<template #default="{ open }">
<button
class="flex w-56 items-center rounded-md px-2 py-2 text-left transition-colors"
:class="open ? 'bg-surface-gray-3' : 'hover:bg-surface-gray-2'"
>
<!-- Gameplan logo -->
<svg
class="size-8 rounded"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 44 44"
>
<path
fill="#F90"
d="M31.429 0H12.57C5.628 0 0 5.628 0 12.571V31.43C0 38.372 5.628 44 12.571 44H31.43C38.372 44 44 38.372 44 31.429V12.57C44 5.628 38.372 0 31.429 0Z"
/>
<path
fill="#fff"
d="M12.571 28.082v-8.093H9.43v7.307a3.93 3.93 0 0 0 3.928 3.929h9.554v-3.143h-10.34Z"
/>
<path
fill="#fff"
d="M30.643 12.76H9.429v3.143h22v12.179h-5.045l-3.457 4.305 2.42 1.996 2.53-3.159h2.766a3.93 3.93 0 0 0 3.928-3.928V16.689a3.93 3.93 0 0 0-3.928-3.929Z"
/>
</svg>
<div class="ml-2 min-w-0">
<div
class="truncate text-base-medium text-ink-gray-8 leading-none"
>
Gameplan
</div>
<div class="mt-1 truncate text-p-sm text-ink-gray-5 leading-none">
Faris
</div>
</div>
<span
class="lucide-chevron-down ml-auto size-4 text-ink-gray-6 transition-transform"
:class="open ? 'rotate-180' : ''"
/>
</button>
</template>
<template #item-suffix="{ selected }">
<span
v-if="selected"
class="lucide-check size-4 text-ink-gray-7"
aria-hidden="true"
/>
</template>
</Dropdown>
<div class="text-p-sm text-ink-gray-5">
Selected theme: <span class="text-ink-gray-7">{{ theme }}</span>
</div>
</div>
</template>Notes
- Prefer
#item-prefix,#item-label, and#item-suffixwhen you want to customize the standard dropdown row. - Use
#itemoritem.componentonly when you need to replace the entire row. Those escape hatches own the outer menu item element, so they should be reserved for exceptional cases.
API Reference
Show types
import type { ButtonProps } from '../Button'
import type { MenuOptions, MenuSlotProps, MenuSlots } from '../Menu/types'
export type {
MenuTheme as DropdownTheme,
MenuSlotFn as DropdownSlotFn,
MenuItemSlots as DropdownItemSlots,
MenuBaseOption as DropdownBaseOption,
MenuActionOption as DropdownActionOption,
MenuSwitchOption as DropdownSwitchOption,
MenuSubmenuOption as DropdownSubmenuOption,
MenuComponentOption as DropdownComponentOption,
MenuGroupOption as DropdownGroupOption,
MenuOption as DropdownOption,
MenuItem as DropdownItem,
MenuOptions as DropdownOptions,
MenuSlotProps as DropdownSlotProps,
MenuItemSlotProps as DropdownItemSlotProps,
MenuGroupSlotProps as DropdownGroupSlotProps,
} from '../Menu/types'
export type DropdownPlacement = 'left' | 'right' | 'center'
export type DropdownSide = 'top' | 'right' | 'bottom' | 'left'
export type DropdownAlign = 'start' | 'center' | 'end'
export interface DropdownProps {
/** Button configuration (label, icon, size, variant, etc.) */
button?: ButtonProps
/** Array of dropdown options or grouped options. */
options?: MenuOptions
/** Controls the visibility of the dropdown. */
open?: boolean
/** Alignment of the dropdown content along the trigger edge. */
align?: DropdownAlign
/**
* Placement of the dropdown relative to the trigger.
* @deprecated use `align` instead; `placement` remains as a back-compat alias through v1.x
*/
placement?: DropdownPlacement
/** Side of the trigger the dropdown appears on. */
side?: DropdownSide
/** Offset in pixels between trigger and dropdown. */
offset?: number
/** Whether the dropdown width should match the trigger element. */
matchTriggerWidth?: boolean
/** Teleport target for dropdown portal content. */
portalTo?: string | HTMLElement
}
export interface DropdownTriggerSlotProps extends MenuSlotProps {
/** Whether the dropdown menu is currently open. */
open: boolean
/** Whether the trigger should render as disabled. */
disabled: boolean
[key: string]: any
}
export type DropdownSlots = Omit<MenuSlots, 'default' | 'trigger'> & {
/** Alternate trigger renderer. */
default?: (props: DropdownTriggerSlotProps) => any
/** Explicit trigger slot renderer. */
trigger?: (props: DropdownTriggerSlotProps) => any
}
export interface DropdownEmits {
/** Fired when the dropdown open state changes. */
'update:open': [value: boolean]
}
export interface DropdownExposed {
/** Closes the dropdown menu. */
close: () => void
}Button configuration (label, icon, size, variant, etc.)
Array of dropdown options or grouped options.
Controls the visibility of the dropdown.
Alignment of the dropdown content along the trigger edge.
Deprecated — use `align` instead; `placement` remains as a back-compat alias through v1.x
Side of the trigger the dropdown appears on.
Offset in pixels between trigger and dropdown.
Teleport target for dropdown portal content.
| Slot | Payload |
|---|---|
default | DropdownTriggerSlotProps Alternate trigger renderer. |
trigger | DropdownTriggerSlotProps Explicit trigger slot renderer. |
Alternate trigger renderer.
Explicit trigger slot renderer.
| Event | Payload |
|---|---|
update:open | [value: boolean] Fired when the open state changes. |
Fired when the open state changes.