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.

Pick an action
vue
<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.

vue
<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>

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.

vue
<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.

vue
<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.

Ship Combobox v1
Due Friday · Engineering
Write migration guide
In review · Design
Audit selection components
Backlog · Platform
vue
<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.

Selected theme: system
vue
<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-suffix when you want to customize the standard dropdown row.
  • Use #item or item.component only 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
typescript
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
ButtonProps

Button configuration (label, icon, size, variant, etc.)

options
= []
MenuOptions

Array of dropdown options or grouped options.

open
= false
boolean

Controls the visibility of the dropdown.

align
DropdownAlign

Alignment of the dropdown content along the trigger edge.

placement

Deprecated — use `align` instead; `placement` remains as a back-compat alias through v1.x

side
= "bottom"
DropdownSide

Side of the trigger the dropdown appears on.

offset
= 4
number

Offset in pixels between trigger and dropdown.

portalTo
= "body"
string | HTMLElement

Teleport target for dropdown portal content.

default
DropdownTriggerSlotProps

Alternate trigger renderer.

trigger
DropdownTriggerSlotProps

Explicit trigger slot renderer.

update:open
[value: boolean]

Fired when the open state changes.