Dropdown

A flexible menu component for actions. Handles groups, nested submenus, toggle rows, disabled items, custom triggers, and a built-in kebab pattern.

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-white">
    <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 font-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 { Component, VNodeChild } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { ButtonProps } from '../Button'

export type DropdownTheme = 'gray' | 'red'
export type DropdownPlacement = 'left' | 'right' | 'center'
export type DropdownSide = 'top' | 'right' | 'bottom' | 'left'
export type DropdownAlign = 'start' | 'center' | 'end'

export type DropdownSlotFn<TProps> = (props: TProps) => VNodeChild

export interface DropdownItemSlots<TProps> {
  /** Replaces the prefix region of the standard row shell. */
  prefix?: DropdownSlotFn<TProps>

  /** Replaces the label region of the standard row shell. */
  label?: DropdownSlotFn<TProps>

  /** Replaces the suffix region of the standard row shell. */
  suffix?: DropdownSlotFn<TProps>

  /** Replaces the entire row; mutually exclusive with `prefix` / `label` / `suffix`. */
  item?: DropdownSlotFn<TProps>
}

export interface DropdownBaseOption {
  /** Leading icon shown for the item row. */
  icon?: string | Component | null

  /** Secondary text shown below the label. */
  description?: string

  /** Marks the item as currently selected. */
  selected?: boolean

  /** Disables interaction for the item. */
  disabled?: boolean

  /** Visual theme applied to the item row. */
  theme?: DropdownTheme

  /** Named slot suffix used to resolve `item-*` slots dynamically. */
  slot?: string

  /** Per-item inline slot implementations for the row shell. */
  slots?: DropdownItemSlots<DropdownItemSlotProps>

  /** Condition used to omit an item from the final menu. */
  condition?: () => boolean

  [key: string]: any
}

export interface DropdownActionOption extends DropdownBaseOption {
  /** Primary label shown for the action item. */
  label: string

  /** Router destination to navigate to when the item is clicked. */
  route?: RouteLocationRaw

  /** Click handler invoked when the action item is selected. */
  onClick?: (event: PointerEvent) => void

  submenu?: never
  switch?: never
  switchValue?: never
  component?: never
}

export interface DropdownSwitchOption extends DropdownBaseOption {
  /** Primary label shown for the switch item. */
  label: string

  /** Renders the item with a switch control. */
  switch: true

  /** Current boolean value for the switch item. */
  switchValue?: boolean

  /** Change handler invoked with the next switch value. */
  onClick?: (value: boolean) => void

  route?: never
  submenu?: never
  component?: never
}

export interface DropdownSubmenuOption extends DropdownBaseOption {
  /** Primary label shown for the submenu trigger. */
  label: string

  /** Nested menu items rendered in the submenu. */
  submenu: DropdownOptions

  route?: never
  onClick?: never
  switch?: never
  switchValue?: never
  component?: never
}

export interface DropdownComponentOption extends DropdownBaseOption {
  /**
   * Custom component rendered in place of the standard menu row.
   * @deprecated use `slots.item` for the full-row escape hatch instead
   */
  component: any

  /** Optional label used by custom renderers. */
  label?: string

  route?: never
  submenu?: never
  switch?: never
  switchValue?: never
}

export interface DropdownGroupOption {
  /** Stable key for the group wrapper. */
  key?: string | number

  /** Label rendered above the grouped items. */
  group: string

  /**
   * Items rendered inside the group. Optional in the type so the
   * deprecated `items` alias still typechecks; provide one of
   * `options` or `items`.
   */
  options?: DropdownOption[]

  /**
   * @deprecated use `options`. Accepted only as a back-compat alias for
   * Dropdown's previous `{ group, items }` shape.
   */
  items?: DropdownOption[]

  /** Hides the group heading while preserving grouping. */
  hideLabel?: boolean

  /** Theme inherited by items in the group. */
  theme?: DropdownTheme
}

export type DropdownOption =
  | DropdownActionOption
  | DropdownSwitchOption
  | DropdownSubmenuOption
  | DropdownComponentOption

export type DropdownItem = DropdownOption | DropdownGroupOption
export type DropdownOptions = Array<DropdownItem>

export interface DropdownProps {
  /** Button configuration (label, icon, size, variant, etc.) */
  button?: ButtonProps

  /** Array of dropdown options or grouped options. */
  options?: DropdownOptions

  /** 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

  /** Teleport target for dropdown portal content. */
  portalTo?: string | HTMLElement
}

export interface DropdownSlotProps {
  /** Closes the dropdown menu. */
  close: () => void
}

export interface DropdownTriggerSlotProps extends DropdownSlotProps {
  /** Whether the dropdown menu is currently open. */
  open: boolean

  /** Whether the trigger should render as disabled. */
  disabled: boolean

  [key: string]: any
}

export interface DropdownItemSlotProps extends DropdownSlotProps {
  /** Item currently being rendered. */
  item: DropdownOption

  /** Whether the item is currently selected. */
  selected: boolean
}

export interface DropdownGroupSlotProps {
  /** Group currently being rendered. */
  group: DropdownGroupOption
}

export interface DropdownSlots {
  /** Alternate trigger renderer. */
  default?: (props: DropdownTriggerSlotProps) => any

  /** Explicit trigger slot renderer. */
  trigger?: (props: DropdownTriggerSlotProps) => any

  /** Replaces the entire item row. */
  item?: (props: DropdownItemSlotProps) => any

  /** Content rendered before the standard item label. */
  'item-prefix'?: (props: DropdownItemSlotProps) => any

  /** Content rendered for the standard item label area. */
  'item-label'?: (props: DropdownItemSlotProps) => any

  /** Content rendered after the standard item label. */
  'item-suffix'?: (props: DropdownItemSlotProps) => any

  /** Custom renderer for group labels. */
  'group-label'?: (props: DropdownGroupSlotProps) => any

  /** Fallback content rendered when no items are available. */
  empty?: () => any

  [slotName: string]: ((props: any) => any) | undefined
}

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
= []
DropdownOptions

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.

item
DropdownItemSlotProps

Replaces the entire item row.

item-prefix
DropdownItemSlotProps

Content rendered before the standard item label.

item-label
DropdownItemSlotProps

Content rendered for the standard item label area.

item-suffix
DropdownItemSlotProps

Content rendered after the standard item label.

group-label
DropdownGroupSlotProps

Custom renderer for group labels.

empty

Fallback content rendered when no items are available.

update:open
[value: boolean]

Fired when the open state changes.