Hacktron Design System

DropdownMenu

A list of actions that opens from a button.

<template>  <DropdownMenu :items="[[{ label: 'Edit', icon: 'i-lucide-pencil', onSelect: onEdit }], [{ label: 'Delete', icon: 'i-lucide-trash', color: 'error', onSelect: onDelete }]]">    <Button variant="secondary" trailing-icon="i-lucide-chevron-down">Actions</Button>  </DropdownMenu></template>

Props

Name Type Default
itemsDropdownMenuItem[] | DropdownMenuItem[][]-

Slots

Name Description
defaultThe trigger. Any element works, but real usage is always a Button - a labeled one with a trailing chevron for most menus, or an icon-only kebab for an overflow menu.

Guidelines

Do

  • Use a labeled trigger with a trailing chevron (trailing-icon="i-lucide-chevron-down") for most menus - the pattern nearly every real usage in apps/frontend follows.
  • Use an icon-only trigger (e.g. icon="i-lucide-more-horizontal", no label) for a kebab/overflow menu, and pass :content="{ align: 'end' }" so it doesn't overflow past the trigger's right edge.
  • Group related items with a 2D array (DropdownMenuItem[][]) - an automatic divider is inserted between groups, no separate type: 'separator' entry needed.
  • Use children on an item for a submenu, and onSelect on each item for its action - both match the real user menu's own org-switching pattern.
  • Use a secondary Button for a filter-style action trigger, and a plain one at the row's size when the trigger sits among small plain actions. A control that picks a value (a date range, a "Group by") is a Select, not a menu.
  • Set color: 'error' on a destructive item (remove, revoke, delete) - it stays red at rest and on hover. Leave its icon out, and the icon of an exit item (log out, leave organization), so it stands apart from the iconned items above it. A menu can mix items with and without icons.
  • For custom content in some rows only, give those items slot: 'name' and fill #name-leading - the org switcher renders each org's Avatar this way.

Don't

  • Don't use a menu to set a field's value (a finding's status or severity): use Select, or Combobox when the list needs search. A menu is for actions.
  • Don't reach for type: 'checkbox' or keyboard shortcuts (kbds) unless the design genuinely needs them - no real usage has either yet, and both add interaction complexity most menus don't call for.
  • Don't forget alignment when the trigger sits near a container's edge - the default alignment can otherwise push the menu outside the viewport.
  • Don't pass :ui - it would replace the whole design-system look (panel and option rows), so the type-check rejects it. Every menu and picker shares one option-row style. The trigger is your own slot content, so style it as a Button.

Examples

Variants

Basic

A grouped menu with a section label and a separator between groups.

const items = [  [    { type: 'label', label: 'My Account' },    { label: 'Profile', icon: 'i-lucide-user' },    { label: 'Billing', icon: 'i-lucide-credit-card' },  ],  [    { label: 'Team', icon: 'i-lucide-users' },    { label: 'Subscription', icon: 'i-lucide-badge-dollar-sign' },  ],]<DropdownMenu :items="items">  <Button variant="secondary" trailing-icon="i-lucide-chevron-down">Open</Button></DropdownMenu>

A nested menu opened from a parent item.

const items = [  { label: 'Profile', icon: 'i-lucide-user' },  {    label: 'Invite users',    icon: 'i-lucide-user-plus',    children: [      { label: 'Email', icon: 'i-lucide-mail' },      { label: 'Message', icon: 'i-lucide-message-square' },      { type: 'separator' },      { label: 'More...', icon: 'i-lucide-plus-circle' },    ],  },  { label: 'Log out', icon: 'i-lucide-log-out' },]<DropdownMenu :items="items">  <Button variant="secondary" trailing-icon="i-lucide-chevron-down">Open</Button></DropdownMenu>

Checkboxes

Independent toggles that stay open after each click.

const showStatusBar = ref(true)const showActivityBar = ref(false)const items = computed(() => [  { type: 'label', label: 'Appearance' },  { type: 'checkbox', label: 'Status Bar', checked: showStatusBar.value, onUpdateChecked: (c) => showStatusBar.value = c },  { type: 'checkbox', label: 'Activity Bar', checked: showActivityBar.value, onUpdateChecked: (c) => showActivityBar.value = c },  { type: 'checkbox', label: 'Panel', disabled: true },])<DropdownMenu :items="items">  <Button variant="secondary" trailing-icon="i-lucide-chevron-down">View</Button></DropdownMenu>

Radio Group

Mutually exclusive options, only one selected at a time.

const paymentMethod = ref('card')// UDropdownMenu has no native radio item type - type: 'checkbox' items that all write to// the same ref, keyed to their own value, approximate one instead.const items = computed(() => [  { type: 'label', label: 'Payment Method' },  { type: 'checkbox', label: 'Credit Card', checked: paymentMethod.value === 'card', onUpdateChecked: () => paymentMethod.value = 'card' },  { type: 'checkbox', label: 'PayPal', checked: paymentMethod.value === 'paypal', onUpdateChecked: () => paymentMethod.value = 'paypal' },  { type: 'checkbox', label: 'Apple Pay', checked: paymentMethod.value === 'apple-pay', onUpdateChecked: () => paymentMethod.value = 'apple-pay' },])<DropdownMenu :items="items">  <Button variant="secondary" trailing-icon="i-lucide-chevron-down">Payment Method</Button></DropdownMenu>

Destructive

A destructive action set apart from the rest of the menu.

const items = [  [    { type: 'label', label: 'Actions' },    { label: 'Edit', icon: 'i-lucide-pencil' },    { label: 'Duplicate', icon: 'i-lucide-copy' },  ],  [    { label: 'Delete', icon: 'i-lucide-trash', color: 'error' },  ],]<DropdownMenu :items="items">  <Button variant="secondary" trailing-icon="i-lucide-chevron-down">Actions</Button></DropdownMenu>

With icons and slots

Shortcuts

Keyboard shortcut hints shown next to each item.

const items = [  { label: 'Profile', kbds: ['meta', 'P'] },  { label: 'Billing', kbds: ['meta', 'B'] },  { label: 'Settings', kbds: ['meta', 'S'] },  { label: 'Keyboard shortcuts', kbds: ['meta', 'K'] },]<DropdownMenu :items="items">  <Button variant="secondary" trailing-icon="i-lucide-chevron-down">Open</Button></DropdownMenu>

Icons

A leading icon on every item.

const items = [  { label: 'Profile', icon: 'i-lucide-user' },  { label: 'Billing', icon: 'i-lucide-credit-card' },  { label: 'Settings', icon: 'i-lucide-settings' },  { label: 'Log out', icon: 'i-lucide-log-out' },]<DropdownMenu :items="items">  <Button variant="secondary" trailing-icon="i-lucide-chevron-down">Open</Button></DropdownMenu>

Checkboxes Icons

Checkbox items whose icon reflects their own checked state.

const notifyPush = ref(true)const notifyEmail = ref(false)const items = computed(() => [  { type: 'label', label: 'Notifications' },  { type: 'checkbox', label: 'Push notifications', icon: notifyPush.value ? 'i-lucide-bell' : 'i-lucide-bell-off', checked: notifyPush.value, onUpdateChecked: (c) => notifyPush.value = c },  { type: 'checkbox', label: 'Email notifications', icon: notifyEmail.value ? 'i-lucide-mail' : 'i-lucide-mail-x', checked: notifyEmail.value, onUpdateChecked: (c) => notifyEmail.value = c },])<DropdownMenu :items="items">  <Button variant="secondary" trailing-icon="i-lucide-chevron-down">Notifications</Button></DropdownMenu>

Radio Icons

A radio group with a leading icon per option.

const radioMethod = ref('card')const items = computed(() => [  { type: 'label', label: 'Payment Method' },  { type: 'checkbox', label: 'Credit Card', icon: 'i-lucide-credit-card', checked: radioMethod.value === 'card', onUpdateChecked: () => radioMethod.value = 'card' },  { type: 'checkbox', label: 'PayPal', icon: 'i-lucide-wallet', checked: radioMethod.value === 'paypal', onUpdateChecked: () => radioMethod.value = 'paypal' },  { type: 'checkbox', label: 'Apple Pay', icon: 'i-lucide-smartphone', checked: radioMethod.value === 'wallet', onUpdateChecked: () => radioMethod.value = 'wallet' },])<DropdownMenu :items="items">  <Button variant="secondary" trailing-icon="i-lucide-chevron-down">Payment Method</Button></DropdownMenu>

Avatar

A user menu opened from an avatar instead of a button.

const items = [  [    { type: 'label', label: 'Leo Rivera' },    { label: 'Profile', icon: 'i-lucide-user' },    { label: 'Settings', icon: 'i-lucide-settings' },  ],  [    { label: 'Log out', icon: 'i-lucide-log-out' },  ],]<DropdownMenu :items="items" :content="{ align: 'end' }">  <Avatar alt="Leo Rivera" size="sm" /></DropdownMenu>

Composition

Complex

Shortcuts, icons, and a submenu combined in one real-world menu.

const items = [  [    { type: 'label', label: 'My Account' },    { label: 'Profile', icon: 'i-lucide-user', kbds: ['meta', 'P'] },    { label: 'Billing', icon: 'i-lucide-credit-card', kbds: ['meta', 'B'] },    {      label: 'Invite users',      icon: 'i-lucide-user-plus',      children: [        { label: 'Email', icon: 'i-lucide-mail' },        { label: 'Message', icon: 'i-lucide-message-square' },      ],    },  ],  [    { label: 'Log out', icon: 'i-lucide-log-out', kbds: ['shift', 'meta', 'Q'] },  ],]<DropdownMenu :items="items">  <Button variant="secondary" trailing-icon="i-lucide-chevron-down">Open</Button></DropdownMenu>