Vue componentFree

Dropdown Menu

Accessible Vue 3 dropdown menu button with icons, separators, headings, disabled items, arrow-key navigation and click-outside close. Tailwind CSS.

by Vueplayv1.0.0Updated 2 hours ago

Install
Full screen ↗

About this component

A menu button for account menus, row actions and export options. It follows the WAI-ARIA menu button pattern: Enter, Space or the arrow keys open it, arrow keys, Home and End move between items, typing a letter jumps to a matching item, and Escape closes it and puts focus back on the button.

Items are plain objects with a label and optional icon, shortcut hint, disabled or danger flag, mixed with separators and small headings. Disabled items are skipped by the keyboard. Clicking outside or tabbing away closes the menu, and it flips above the button automatically when there isn't room below.

Use the default button with a chevron, the compact ⋯ icon variant for table rows, or replace the button content entirely with the button slot. The header slot is handy for a "Signed in as" block. Listen for select to handle the chosen item.

  • Menu button pattern with roving focus and typeahead
  • Icons, shortcut hints, separators, headings and danger items
  • Disabled items skipped by the keyboard
  • Click-outside and Escape close with focus return
  • Auto-flips above the button near the viewport edge

Code

11 files in the repo
<script lang="ts">
export interface MenuItem {
  type?: 'item'
  id: string
  label: string
  /** SVG path data for a 20×20 solid icon. */
  icon?: string
  /** Shortcut hint shown on the right, e.g. '⌘D'. Display only. */
  shortcut?: string
  disabled?: boolean
  /** Styles the item as destructive. */
  danger?: boolean
}

export interface MenuSeparator {
  type: 'separator'
}

export interface MenuHeading {
  type: 'heading'
  label: string
}

export type MenuEntry = MenuItem | MenuSeparator | MenuHeading

/** Small 20×20 solid icons used by the default items. */
export const menuIcons = {
  pencil: 'M14.1 2.9a2.1 2.1 0 0 1 3 3l-9.4 9.4a2 2 0 0 1-.9.52l-3.3.95a.6.6 0 0 1-.74-.74l.95-3.3a2 2 0 0 1 .52-.9l9.4-9.4Zm-1.06 2.12-1.4 1.4 1.94 1.94 1.4-1.4-1.94-1.94Z',
  copy: 'M7 3.5A1.5 1.5 0 0 1 8.5 2h7A1.5 1.5 0 0 1 17 3.5v9a1.5 1.5 0 0 1-1.5 1.5H14V7.5A2.5 2.5 0 0 0 11.5 5H7V3.5ZM3 7.5A1.5 1.5 0 0 1 4.5 6h7A1.5 1.5 0 0 1 13 7.5v9a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 16.5v-9Z',
  share: 'M13 4.5a2.5 2.5 0 1 1 .7 1.74L7.9 9.1a2.5 2.5 0 0 1 0 1.8l5.8 2.86a2.5 2.5 0 1 1-.66 1.35l-5.8-2.87a2.5 2.5 0 1 1 0-4.48l5.8-2.87A2.5 2.5 0 0 1 13 4.5Z',
  archive: 'M2 4a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4Zm1 4.5h14V15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8.5ZM7.75 10a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-4.5Z',
  download: 'M10 2.75a.75.75 0 0 1 .75.75v7.19l2.22-2.22a.75.75 0 1 1 1.06 1.06l-3.5 3.5a.75.75 0 0 1-1.06 0l-3.5-3.5a.75.75 0 1 1 1.06-1.06l2.22 2.22V3.5a.75.75 0 0 1 .75-.75ZM3.5 13a.75.75 0 0 1 .75.75v1.5c0 .14.11.25.25.25h11a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 15.5 17h-11a1.75 1.75 0 0 1-1.75-1.75v-1.5A.75.75 0 0 1 3.5 13Z',
  trash: 'M8.5 2a1.5 1.5 0 0 0-1.5 1.5V4H4a.75.75 0 0 0 0 1.5h.3l.8 10.1A2.5 2.5 0 0 0 7.6 18h4.8a2.5 2.5 0 0 0 2.5-2.4l.8-10.1h.3a.75.75 0 0 0 0-1.5h-3v-.5A1.5 1.5 0 0 0 11.5 2h-3Zm0 6a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5A.75.75 0 0 1 8.5 8Zm3 0a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5A.75.75 0 0 1 11.5 8Z',
  user: 'M10 9a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm-6.5 8.25a6.5 6.5 0 0 1 13 0 .75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75Z',
  cog: 'M8.3 2.2a1 1 0 0 1 1-.7h1.4a1 1 0 0 1 1 .7l.37 1.3c.4.16.77.37 1.1.62l1.32-.33a1 1 0 0 1 1.1.47l.7 1.22a1 1 0 0 1-.13 1.17l-.95.98a5 5 0 0 1 0 1.27l.95.98a1 1 0 0 1 .13 1.17l-.7 1.22a1 1 0 0 1-1.1.47l-1.32-.33c-.33.25-.7.46-1.1.62l-.37 1.3a1 1 0 0 1-1 .7H9.3a1 1 0 0 1-1-.7l-.37-1.3a5 5 0 0 1-1.1-.62l-1.32.33a1 1 0 0 1-1.1-.47l-.7-1.22a1 1 0 0 1 .13-1.17l.95-.98a5 5 0 0 1 0-1.27l-.95-.98a1 1 0 0 1-.13-1.17l.7-1.22a1 1 0 0 1 1.1-.47l1.32.33c.33-.25.7-.46 1.1-.62l.37-1.3ZM10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
  logout: 'M3 4.25A2.25 2.25 0 0 1 5.25 2h5.5A2.25 2.25 0 0 1 13 4.25v1a.75.75 0 0 1-1.5 0v-1a.75.75 0 0 0-.75-.75h-5.5a.75.75 0 0 0-.75.75v11.5c0 .41.34.75.75.75h5.5a.75.75 0 0 0 .75-.75v-1a.75.75 0 0 1 1.5 0v1A2.25 2.25 0 0 1 10.75 18h-5.5A2.25 2.25 0 0 1 3 15.75V4.25Zm11.22 2.47a.75.75 0 0 1 1.06 0l2.75 2.75a.75.75 0 0 1 0 1.06l-2.75 2.75a.75.75 0 1 1-1.06-1.06l1.47-1.47H8.75a.75.75 0 0 1 0-1.5h6.94l-1.47-1.47a.75.75 0 0 1 0-1.06Z',
} as const

export const defaultMenuItems: MenuEntry[] = [
  { id: 'edit', label: 'Edit', icon: menuIcons.pencil, shortcut: '⌘E' },
  { id: 'duplicate', label: 'Duplicate', icon: menuIcons.copy, shortcut: '⌘D' },
  { id: 'share', label: 'Share', icon: menuIcons.share },
  { type: 'separator' },
  { id: 'download', label: 'Download PDF', icon: menuIcons.download },
  { id: 'archive', label: 'Archive', icon: menuIcons.archive, disabled: true },
  { type: 'separator' },
  { id: 'delete', label: 'Delete', icon: menuIcons.trash, danger: true, shortcut: '⌫' },
]
</script>

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'

const props = withDefaults(defineProps<{
  items?: MenuEntry[]
  /** Button text, or the accessible label when `variant` is 'icon'. */
  label?: string
  /** 'button' shows the label with a chevron; 'icon' is a compact ⋯ button. */
  variant?: 'button' | 'icon'
  /** Horizontal alignment of the menu relative to the button. */
  align?: 'start' | 'end'
  /** Tailwind width class for the menu panel. */
  width?: string
  disabled?: boolean
}>(), {
  items: () => defaultMenuItems,
  label: 'Options',
  variant: 'button',
  align: 'start',
  width: 'w-56',
  disabled: false,
})

const emit = defineEmits<{
  select: [item: MenuItem]
  open: []
  close: []
}>()

defineSlots<{
  /** Custom button content. */
  button?: (props: { open: boolean }) => unknown
  /** Content above the items, e.g. "Signed in as". */
  header?: () => unknown
}>()

const uid = useId()
const buttonId = `${uid}-button`
const menuId = `${uid}-menu`

const root = ref<HTMLElement | null>(null)
const button = ref<HTMLButtonElement | null>(null)
const menu = ref<HTMLElement | null>(null)
const open = ref(false)
const activeId = ref<string | null>(null)
const placeAbove = ref(false)
const resolvedAlign = ref<'start' | 'end'>(props.align)

const isItem = (e: MenuEntry): e is MenuItem => e.type === undefined || e.type === 'item'
const enabled = computed(() => props.items.filter(isItem).filter((i) => !i.disabled))

function focusItem(id: string | null | undefined) {
  if (!id) return
  activeId.value = id
  menu.value?.querySelector<HTMLElement>(`[data-id="${CSS.escape(id)}"]`)?.focus()
}

async function openMenu(focus: 'first' | 'last' | 'none' = 'first') {
  if (props.disabled) return
  if (!open.value) {
    open.value = true
    emit('open')
  }
  await nextTick()
  updatePlacement()
  if (focus === 'first') focusItem(enabled.value[0]?.id)
  else if (focus === 'last') focusItem(enabled.value[enabled.value.length - 1]?.id)
  else menu.value?.focus()
}

function closeMenu(returnFocus = true) {
  if (!open.value) return
  open.value = false
  activeId.value = null
  emit('close')
  if (returnFocus) button.value?.focus()
}

function choose(item: MenuItem) {
  if (item.disabled) return
  emit('select', item)
  closeMenu()
}

// Flip above the button if there isn't room below.
function updatePlacement() {
  if (!button.value || !menu.value) return
  const rect = button.value.getBoundingClientRect()
  const height = menu.value.offsetHeight
  const below = window.innerHeight - rect.bottom
  placeAbove.value = below < height + 12 && rect.top > below
  // Swap alignment when the preferred side would push the menu off-screen horizontally.
  const width = menu.value.offsetWidth
  const fitsEnd = rect.right - width >= 8
  const fitsStart = rect.left + width <= window.innerWidth - 8
  resolvedAlign.value = props.align === 'end' ? (fitsEnd || !fitsStart ? 'end' : 'start') : (fitsStart || !fitsEnd ? 'start' : 'end')
}

function move(delta: 1 | -1) {
  const list = enabled.value
  if (!list.length) return
  const current = list.findIndex((i) => i.id === activeId.value)
  const next = current === -1 ? (delta > 0 ? 0 : list.length - 1) : (current + delta + list.length) % list.length
  focusItem(list[next]?.id)
}

let typeahead = ''
let typeaheadTimer: ReturnType<typeof setTimeout> | undefined
function onTypeahead(char: string) {
  typeahead += char.toLowerCase()
  clearTimeout(typeaheadTimer)
  typeaheadTimer = setTimeout(() => (typeahead = ''), 500)
  const list = enabled.value
  const start = list.findIndex((i) => i.id === activeId.value)
  const ordered = [...list.slice(start + 1), ...list.slice(0, start + 1)]
  const match = ordered.find((i) => i.label.toLowerCase().startsWith(typeahead))
    ?? (typeahead.length === 1 ? undefined : ordered.find((i) => i.label.toLowerCase().startsWith(char.toLowerCase())))
  if (match) focusItem(match.id)
}

function onButtonKeydown(event: KeyboardEvent) {
  if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
    event.preventDefault()
    openMenu('first')
  } else if (event.key === 'ArrowUp') {
    event.preventDefault()
    openMenu('last')
  }
}

function onMenuKeydown(event: KeyboardEvent) {
  const list = enabled.value
  switch (event.key) {
    case 'ArrowDown': event.preventDefault(); move(1); break
    case 'ArrowUp': event.preventDefault(); move(-1); break
    case 'Home': event.preventDefault(); focusItem(list[0]?.id); break
    case 'End': event.preventDefault(); focusItem(list[list.length - 1]?.id); break
    case 'Escape': event.preventDefault(); event.stopPropagation(); closeMenu(); break
    case 'Tab': closeMenu(false); break
    case 'Enter':
    case ' ': {
      event.preventDefault()
      const item = list.find((i) => i.id === activeId.value)
      if (item) choose(item)
      break
    }
    default:
      if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) onTypeahead(event.key)
  }
}

// Moving the pointer off an item clears the highlight, like native menus.
function onItemLeave(id: string) {
  if (activeId.value !== id) return
  activeId.value = null
  menu.value?.focus()
}

function onDocumentPointerDown(event: PointerEvent) {
  if (open.value && root.value && !root.value.contains(event.target as Node)) closeMenu(false)
}

function onWindowChange() {
  if (open.value) updatePlacement()
}

watch(() => props.disabled, (d) => d && closeMenu(false))

onMounted(() => {
  document.addEventListener('pointerdown', onDocumentPointerDown)
  window.addEventListener('resize', onWindowChange)
  window.addEventListener('scroll', onWindowChange, true)
})
onBeforeUnmount(() => {
  document.removeEventListener('pointerdown', onDocumentPointerDown)
  window.removeEventListener('resize', onWindowChange)
  window.removeEventListener('scroll', onWindowChange, true)
  clearTimeout(typeaheadTimer)
})
</script>

<template>
  <div ref="root" class="relative inline-block text-left">
    <button
      :id="buttonId"
      ref="button"
      type="button"
      aria-haspopup="menu"
      :aria-expanded="open"
      :aria-controls="open ? menuId : undefined"
      :aria-label="variant === 'icon' ? label : undefined"
      :disabled="disabled"
      class="inline-flex items-center justify-center gap-1.5 text-sm font-semibold transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:outline-sky-400"
      :class="variant === 'icon'
        ? ['size-9 rounded-lg text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white', open ? 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-white' : '']
        : 'rounded-lg bg-white px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-gray-300 hover:bg-gray-50 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:hover:bg-white/10'"
      @click="open ? closeMenu() : openMenu('none')"
      @keydown="onButtonKeydown"
    >
      <slot name="button" :open="open">
        <template v-if="variant === 'icon'">
          <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-5">
            <path d="M3 10a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Zm5.5 0a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM15.5 8.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z" />
          </svg>
        </template>
        <template v-else>
          {{ label }}
          <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="-mr-1 size-4 text-gray-400 transition-transform" :class="open ? 'rotate-180' : ''">
            <path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
          </svg>
        </template>
      </slot>
    </button>

    <Transition
      enter-active-class="transition duration-150 ease-out"
      enter-from-class="opacity-0 scale-95"
      leave-active-class="transition duration-100 ease-in"
      leave-to-class="opacity-0 scale-95"
    >
      <div
        v-if="open"
        :id="menuId"
        ref="menu"
        role="menu"
        tabindex="-1"
        :aria-labelledby="buttonId"
        class="absolute z-40 max-w-[calc(100vw-2rem)] rounded-xl bg-white p-1.5 shadow-lg ring-1 ring-gray-900/10 outline-none dark:bg-gray-900 dark:shadow-black/40 dark:ring-white/10"
        :class="[
          width,
          resolvedAlign === 'end' ? 'right-0' : 'left-0',
          placeAbove ? 'bottom-full mb-2' : 'top-full mt-2',
          placeAbove ? (resolvedAlign === 'end' ? 'origin-bottom-right' : 'origin-bottom-left') : (resolvedAlign === 'end' ? 'origin-top-right' : 'origin-top-left'),
        ]"
        @keydown="onMenuKeydown"
      >
        <div v-if="$slots.header" class="-mx-1.5 -mt-1.5 mb-1.5 border-b border-gray-100 px-3.5 py-3 dark:border-white/10">
          <slot name="header" />
        </div>
        <template v-for="(entry, i) in items" :key="isItem(entry) ? entry.id : `${entry.type}-${i}`">
          <div v-if="entry.type === 'separator'" role="separator" class="-mx-1.5 my-1.5 h-px bg-gray-100 dark:bg-white/10" />
          <div v-else-if="entry.type === 'heading'" role="presentation" class="px-2.5 pt-2 pb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
            {{ entry.label }}
          </div>
          <button
            v-else
            type="button"
            role="menuitem"
            tabindex="-1"
            :data-id="entry.id"
            :aria-disabled="entry.disabled || undefined"
            class="group flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm outline-none"
            :class="[
              entry.disabled
                ? 'cursor-not-allowed text-gray-400 dark:text-gray-600'
                : entry.danger
                  ? 'text-red-600 focus:bg-red-50 dark:text-red-400 dark:focus:bg-red-500/10'
                  : 'text-gray-700 focus:bg-gray-100 focus:text-gray-900 dark:text-gray-300 dark:focus:bg-white/10 dark:focus:text-white',
            ]"
            @click="choose(entry)"
            @pointermove="!entry.disabled && activeId !== entry.id && focusItem(entry.id)"
            @pointerleave="onItemLeave(entry.id)"
          >
            <svg v-if="entry.icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4 shrink-0" :class="entry.disabled ? '' : entry.danger ? 'text-red-500 dark:text-red-400' : 'text-gray-400 group-focus:text-sky-600 dark:text-gray-500 dark:group-focus:text-sky-400'">
              <path fill-rule="evenodd" clip-rule="evenodd" :d="entry.icon" />
            </svg>
            <span class="flex-1 truncate">{{ entry.label }}</span>
            <kbd v-if="entry.shortcut" class="font-sans text-xs text-gray-400 dark:text-gray-500">{{ entry.shortcut }}</kbd>
          </button>
        </template>
      </div>
    </Transition>
  </div>
</template>

Props

PropTypeDefaultDescription
itemsMenuEntry[]defaultMenuItems (Edit, Duplicate, Share, Download, Archive, Delete)Menu contents. Items have id, label and optional icon (20×20 SVG path), shortcut, disabled and danger. Add { type: 'separator' } or { type: 'heading', label } between them.
labelstring'Options'Button text, or the accessible label when variant is 'icon'.
variant'button' | 'icon''button''button' shows the label with a chevron; 'icon' is a compact ⋯ button for rows and cards.
align'start' | 'end''start'Aligns the menu to the left or right edge of the button.
widthstring'w-56'Tailwind width class for the menu panel.
disabledbooleanfalseDisables the button and closes the menu.

Events

EventPayloadDescription
select(item: MenuItem)Fired when an enabled item is chosen by click, Enter or Space.
open()Fired when the menu opens.
close()Fired when the menu closes.

Slots

SlotDescription
buttonCustom button content. Receives { open }.
headerContent shown above the items, e.g. the signed-in user.
All navigation →
Preview of Command Palette$9

Component · Navigation

Command Palette

A ⌘K / Ctrl+K command palette for Vue 3 with fuzzy search, grouped results, recent items and full keyboard navigation. Tailwind CSS, dark mode.

by Vueplay
Preview of LinkFree

Component · Navigation

Link

A flexible link component that can act as a router-link for internal navigation or an href for external URLs. Can be used on its own or inside a Navigator.

Preview of Pagination BarFree

Component · Navigation

Pagination Bar

Vue 3 pagination bar with v-model:page, smart ellipsis, prev/next, a page-size picker, compact mobile view and a “Showing X–Y of Z” summary. Tailwind CSS.

by Vueplay
Preview of Sidebar NavFree

Component · Navigation

Sidebar Nav

Vue 3 app sidebar with sections, icons, badges, collapsible groups, an icon-only collapsed mode and a mobile off-canvas drawer. Tailwind CSS.

by Vueplay

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings