DropdownMenu

A set of actions revealed by clicking a trigger.

<script setup lang="ts">
  import { Copy, Download, Pencil, Trash2 } from '@lucide/vue'
  import {
    Button,
    DisclosureIcon,
    DropdownMenu,
    DropdownMenuItem,
    DropdownMenuSeparator,
  } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="File actions" align="start">
    <Button variant="outline" tone="neutral">
      File
      <template #trailing>
        <DisclosureIcon />
      </template>
    </Button>
    <template #content>
      <DropdownMenuItem>
        <template #icon>
          <Pencil />
        </template>
        Rename
      </DropdownMenuItem>
      <DropdownMenuItem>
        <template #icon>
          <Copy />
        </template>
        Copy
      </DropdownMenuItem>
      <DropdownMenuItem>
        <template #icon>
          <Download />
        </template>
        Download
      </DropdownMenuItem>
      <DropdownMenuSeparator />
      <DropdownMenuItem tone="danger">
        <template #icon>
          <Trash2 />
        </template>
        Delete
      </DropdownMenuItem>
    </template>
  </DropdownMenu>
</template>
vue

Usage

import { DropdownMenu, DropdownMenuItem } from '@hina-ui/vue'
ts

The default slot is the trigger and the content slot holds the items. Clicking the trigger opens the menu, and choosing an item closes it again.

<script setup lang="ts">
  import { Button, DropdownMenu, DropdownMenuItem } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="More actions">
    <Button variant="outline" tone="neutral">More actions</Button>
    <template #content>
      <DropdownMenuItem>Edit</DropdownMenuItem>
      <DropdownMenuItem>Copy</DropdownMenuItem>
      <DropdownMenuItem>Archive</DropdownMenuItem>
    </template>
  </DropdownMenu>
</template>
vue

Examples

Items

An item takes a leading icon in its icon slot and a trailing marker in its trailing slot. Set tone="danger" for actions that cannot be undone, such as deleting.

<script setup lang="ts">
  import { ExternalLink, Pencil, Share2, Trash2 } from '@lucide/vue'
  import { Button, DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="Items">
    <Button variant="outline" tone="neutral">Items</Button>
    <template #content>
      <DropdownMenuItem>
        <template #icon>
          <Pencil />
        </template>
        Edit
      </DropdownMenuItem>
      <DropdownMenuItem>
        <template #icon>
          <Share2 />
        </template>
        Share
        <template #trailing>
          <ExternalLink class="text-faint size-3.5" />
        </template>
      </DropdownMenuItem>
      <DropdownMenuSeparator />
      <DropdownMenuItem tone="danger">
        <template #icon>
          <Trash2 />
        </template>
        Delete
      </DropdownMenuItem>
    </template>
  </DropdownMenu>
</template>
vue

Headings and separators

DropdownMenuLabel is a heading that cannot be selected, and DropdownMenuSeparator draws a rule between groups.

<script setup lang="ts">
  import {
    Avatar,
    Button,
    DropdownMenu,
    DropdownMenuItem,
    DropdownMenuLabel,
    DropdownMenuSeparator,
  } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="Account">
    <Button variant="outline" tone="neutral">
      <template #icon>
        <Avatar size="sm" src="/avatars/huh.webp" alt="Shion Hoshimi" />
      </template>
      Shion Hoshimi
    </Button>
    <template #content>
      <DropdownMenuLabel>My account</DropdownMenuLabel>
      <DropdownMenuItem>Profile</DropdownMenuItem>
      <DropdownMenuItem>Preferences</DropdownMenuItem>
      <DropdownMenuSeparator />
      <DropdownMenuLabel>Workspace</DropdownMenuLabel>
      <DropdownMenuItem>Members</DropdownMenuItem>
      <DropdownMenuItem>Billing</DropdownMenuItem>
    </template>
  </DropdownMenu>
</template>
vue

Shortcuts

Put the shortcut for an item in its trailing slot, rendered with Kbd. It is only a label; the key binding itself is still registered by the page.

<script setup lang="ts">
  import { Copy, Scissors, Trash2 } from '@lucide/vue'
  import { Button, DropdownMenu, DropdownMenuItem, Kbd } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="Edit">
    <Button variant="outline" tone="neutral">Edit</Button>
    <template #content>
      <DropdownMenuItem>
        <template #icon>
          <Copy />
        </template>
        Copy
        <template #trailing>
          <Kbd>Ctrl C</Kbd>
        </template>
      </DropdownMenuItem>
      <DropdownMenuItem>
        <template #icon>
          <Scissors />
        </template>
        Cut
        <template #trailing>
          <Kbd>Ctrl X</Kbd>
        </template>
      </DropdownMenuItem>
      <DropdownMenuItem tone="danger">
        <template #icon>
          <Trash2 />
        </template>
        Delete
        <template #trailing>
          <Kbd>Del</Kbd>
        </template>
      </DropdownMenuItem>
    </template>
  </DropdownMenu>
</template>
vue

Groups

DropdownMenuGroup collects related items into one group. Paired with DropdownMenuLabel, screen readers announce that heading as the name of the whole group.

<script setup lang="ts">
  import {
    Avatar,
    Button,
    DropdownMenu,
    DropdownMenuGroup,
    DropdownMenuItem,
    DropdownMenuLabel,
    DropdownMenuSeparator,
  } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="Account">
    <Button variant="outline" tone="neutral">
      <template #icon>
        <Avatar size="sm" src="/avatars/huh.webp" alt="Shion Hoshimi" />
      </template>
      Shion Hoshimi
    </Button>
    <template #content>
      <DropdownMenuGroup>
        <DropdownMenuLabel>My account</DropdownMenuLabel>
        <DropdownMenuItem>Profile</DropdownMenuItem>
        <DropdownMenuItem>Preferences</DropdownMenuItem>
      </DropdownMenuGroup>
      <DropdownMenuSeparator />
      <DropdownMenuGroup>
        <DropdownMenuLabel>Workspace</DropdownMenuLabel>
        <DropdownMenuItem>Members</DropdownMenuItem>
        <DropdownMenuItem>Billing</DropdownMenuItem>
      </DropdownMenuGroup>
    </template>
  </DropdownMenu>
</template>
vue

Checkbox items

DropdownMenuCheckboxItem is for options that can be on at the same time, and checked supports two-way binding. A tick appears at the end of the item once it is on, and the menu stays open so several can be ticked in a row.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuLabel } from '@hina-ui/vue'

  const showCover = ref(true)
  const showSummary = ref(false)
  const showTags = ref(true)
</script>

<template>
  <DropdownMenu label="Columns">
    <Button variant="outline" tone="neutral">Columns</Button>
    <template #content>
      <DropdownMenuLabel>Show in list</DropdownMenuLabel>
      <DropdownMenuCheckboxItem v-model:checked="showCover">Cover</DropdownMenuCheckboxItem>
      <DropdownMenuCheckboxItem v-model:checked="showSummary">Summary</DropdownMenuCheckboxItem>
      <DropdownMenuCheckboxItem v-model:checked="showTags">Tags</DropdownMenuCheckboxItem>
    </template>
  </DropdownMenu>
</template>
vue

Radio items

Wrap a set of mutually exclusive options in DropdownMenuRadioGroup, and the current one is checked automatically. They render as menuitemradio, so screen readers announce which one is selected.

<script setup lang="ts">
  import { ref } from 'vue'
  import {
    Button,
    DropdownMenu,
    DropdownMenuLabel,
    DropdownMenuRadioGroup,
    DropdownMenuRadioItem,
  } from '@hina-ui/vue'

  const sort = ref('newest')
  const labels: Record<string, string> = {
    newest: 'Newest',
    popular: 'Most saved',
    rating: 'Highest rated',
  }
</script>

<template>
  <DropdownMenu label="Sort by">
    <Button variant="outline" tone="neutral">Sort: {{ labels[sort] }}</Button>
    <template #content>
      <DropdownMenuLabel>Sort by</DropdownMenuLabel>
      <DropdownMenuRadioGroup v-model="sort">
        <DropdownMenuRadioItem value="newest">Newest</DropdownMenuRadioItem>
        <DropdownMenuRadioItem value="popular">Most saved</DropdownMenuRadioItem>
        <DropdownMenuRadioItem value="rating">Highest rated</DropdownMenuRadioItem>
      </DropdownMenuRadioGroup>
    </template>
  </DropdownMenu>
</template>
vue

Placement

side decides which way the menu opens and align how it lines up with the trigger. The default is centred below.

<script setup lang="ts">
  import { Button, DropdownMenu, DropdownMenuItem, Inline } from '@hina-ui/vue'
</script>

<template>
  <Inline align="center" class="gap-6">
    <DropdownMenu label="Aligned to the start" align="start">
      <Button variant="outline" tone="neutral">start</Button>
      <template #content>
        <DropdownMenuItem>First item</DropdownMenuItem>
        <DropdownMenuItem>Second item</DropdownMenuItem>
      </template>
    </DropdownMenu>
    <DropdownMenu label="Centred" align="center">
      <Button variant="outline" tone="neutral">center</Button>
      <template #content>
        <DropdownMenuItem>First item</DropdownMenuItem>
        <DropdownMenuItem>Second item</DropdownMenuItem>
      </template>
    </DropdownMenu>
    <DropdownMenu label="Opening to the right" side="right" align="start">
      <Button variant="outline" tone="neutral">right</Button>
      <template #content>
        <DropdownMenuItem>First item</DropdownMenuItem>
        <DropdownMenuItem>Second item</DropdownMenuItem>
      </template>
    </DropdownMenu>
  </Inline>
</template>
vue

Controlled

open supports two-way binding, so the menu can be opened or closed from outside.

Currently: closed

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, DropdownMenu, DropdownMenuItem, Inline, Text } from '@hina-ui/vue'

  const open = ref(false)
</script>

<template>
  <Inline align="center">
    <DropdownMenu v-model:open="open" label="Controlled menu">
      <Button variant="outline" tone="neutral">Menu</Button>
      <template #content>
        <DropdownMenuItem>First item</DropdownMenuItem>
        <DropdownMenuItem>Second item</DropdownMenuItem>
      </template>
    </DropdownMenu>
    <Button size="sm" variant="soft" tone="neutral" @click="open = !open">
      {{ open ? 'Close' : 'Open' }} from outside
    </Button>
    <Text tone="muted" size="sm">Currently: {{ open ? 'open' : 'closed' }}</Text>
  </Inline>
</template>
vue

External anchor

anchor accepts OverlayAnchor | null. Omit the default slot and control visibility with v-model:open. The menu waits for its anchor. Changing the anchor while open updates positioning; clearing it on close preserves the exit position.

When both the default slot and anchor are provided, the slot controls triggering and anchor controls positioning. The caller manages click and keyboard behavior, aria-haspopup="menu", and aria-expanded on the external element. Keyboard navigation inside the menu stays the same. Positioning follows the same rules as Popover.

<script setup lang="ts">
  import { ref, shallowRef } from 'vue'
  import { Button, DropdownMenu, DropdownMenuItem, Inline } from '@hina-ui/vue'

  const open = ref(false)
  const current = ref(0)
  const anchor = shallowRef<HTMLElement | null>(null)

  function toggle(event: MouseEvent | KeyboardEvent, index: number) {
    const target = event.currentTarget as HTMLElement
    open.value = event.type === 'keydown' || anchor.value !== target || !open.value
    anchor.value = target
    current.value = index
  }
</script>

<template>
  <Inline>
    <Button
      v-for="index in 2"
      :key="index"
      variant="outline"
      tone="neutral"
      aria-haspopup="menu"
      :aria-expanded="open && current === index"
      @click="toggle($event, index)"
      @keydown.down.prevent="toggle($event, index)"
    >
      Anchor {{ index }}
    </Button>
    <DropdownMenu v-model:open="open" :anchor="anchor" :modal="false" label="External menu">
      <template #content>
        <DropdownMenuItem>Copy</DropdownMenuItem>
        <DropdownMenuItem>Rename</DropdownMenuItem>
        <DropdownMenuItem disabled>Unavailable</DropdownMenuItem>
      </template>
    </DropdownMenu>
  </Inline>
</template>
vue

Virtual anchors and continuous tracking

anchor also accepts an object with getBoundingClientRect() returning a rectangle in viewport coordinates. Import OverlayAnchor from the package root. The callback must return the latest coordinates; the object itself does not need to be replaced.

The optional contextElement identifies the element associated with those coordinates, allowing scroll ancestors and clipping boundaries to be detected. It does not become a trigger or extend the overlay’s interaction area.

updatePositionStrategy defaults to 'optimized', updating on scrolling, resizing, and layout changes. Set it to 'always' to check the rectangle every frame while mounted, including coordinate changes without DOM events. The strategy can change while open. Tracking continues throughout exit; clearing the anchor or removing its context element preserves the last position. Measurement stops after unmount.

Virtual anchors only change positioning. The menu retains automatic focus, arrow-key navigation, and item selection. Use Popover for free-form content that needs to preserve external focus.

The example opens with Button and uses ScrollArea as its scroll container. The panel follows changing coordinates and container scrolling.

Scroll down to observe the position update.

<script setup lang="ts">
  import { ref, shallowRef, watch } from 'vue'
  import { useRafFn } from '@vueuse/core'
  import {
    Button,
    ScrollArea,
    Inline,
    Stack,
    Text,
    DropdownMenu,
    DropdownMenuItem,
    type OverlayAnchor,
  } from '@hina-ui/vue'

  const open = ref(false)
  const surface = ref<InstanceType<typeof Stack>>()
  const anchor = shallowRef<OverlayAnchor | null>(null)
  const x = ref(80)
  const { pause, resume } = useRafFn(
    ({ delta }) => {
      x.value = 80 + ((x.value - 80 + delta / 35) % 120)
    },
    { immediate: false },
  )
  watch(open, value => (value ? resume() : pause()))

  function show() {
    const element = surface.value?.$el as HTMLElement | undefined
    if (!element) return
    anchor.value = {
      contextElement: element,
      getBoundingClientRect: () => {
        const rect = element.getBoundingClientRect()
        return new DOMRect(rect.left + x.value, rect.top + 100, 2, 20)
      },
    }
    open.value = true
  }
</script>

<template>
  <Stack class="w-full">
    <Inline>
      <Button variant="outline" tone="neutral" @click="show">Open</Button>
      <Text size="sm" tone="muted">Scroll down to observe the position update.</Text>
    </Inline>
    <ScrollArea class="h-64 rounded-lg border border-line bg-inset">
      <Stack ref="surface" class="relative h-128 shrink-0">
        <Text
          as="span"
          aria-hidden="true"
          class="absolute top-25 h-5 w-0.5 bg-accent"
          :style="{ left: `${x}px` }"
        />
      </Stack>
    </ScrollArea>
    <DropdownMenu
      v-model:open="open"
      :anchor="anchor"
      update-position-strategy="always"
      align="start"
      :modal="false"
    >
      <template #content>
        <DropdownMenuItem>First item</DropdownMenuItem>
        <DropdownMenuItem>Second item</DropdownMenuItem>
      </template>
    </DropdownMenu>
  </Stack>
</template>
vue

Unavailable items

An item with disabled cannot be clicked and is skipped while moving through the menu with the keyboard.

<script setup lang="ts">
  import { Button, DropdownMenu, DropdownMenuItem } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="Export">
    <Button variant="outline" tone="neutral">Export</Button>
    <template #content>
      <DropdownMenuItem>Export as PDF</DropdownMenuItem>
      <DropdownMenuItem disabled>Export as EPUB</DropdownMenuItem>
      <DropdownMenuItem>Export as plain text</DropdownMenuItem>
    </template>
  </DropdownMenu>
</template>
vue

Behaviour

  • In the default modal mode, the page is locked from scrolling while the menu is open.
  • The trigger keeps its pressed ink for as long as the menu is open.
  • Arrow keys move between items and wrap around, typing jumps to a matching item, Enter selects, and Escape closes the menu and returns focus to the trigger. The right arrow key opens a submenu and the left arrow key closes it.
  • Choosing an ordinary item or a radio item closes the menu; ticking a checkbox item leaves it open.
  • In the default modal mode, clicking outside closes the menu without passing the click to the element underneath.

Accessibility

  • The trigger carries aria-haspopup="menu", the menu is a role="menu" and items are role="menuitem".
  • Give the menu a name through label; screen readers announce it on entry.
  • Radio items render as menuitemradio and checkbox items as menuitemcheckbox, both with aria-checked.
  • The parent item of a submenu carries aria-haspopup="menu" and aria-expanded.

API

DropdownMenu

Prop
Type
Default
Description
open
boolean
—
Whether it is open; supports v-model
label
string
—
Accessible name of the menu
anchor
OverlayAnchor | null
—
Positioning element or virtual anchor
updatePositionStrategy
'optimized' | 'always'
'optimized'
Position update strategy
modal
boolean
true
Restrict outside interaction and lock scrolling
dir
'ltr' | 'rtl'
Inherited
Menu direction
side
'top' | 'right' | 'bottom' | 'left'
'bottom'
Which way it opens
align
'start' | 'center' | 'end'
'center'
How it lines up with the trigger
sideOffset
number
8
Distance from the trigger
class
string
—
Classes appended to the panel
Slot
Description
default
Optional trigger
content
The items
EventArgument
Description
closeAutoFocusEvent
Before restoring focus on close; can be prevented
escapeKeyDownKeyboardEvent
Escape key; dismissal can be prevented
pointerDownOutsidePointerDownOutsideEvent
Outside pointer down; dismissal can be prevented
focusOutsideFocusOutsideEvent
Focus moves outside; dismissal can be prevented
interactOutsidePointerDownOutsideEvent | FocusOutsideEvent
Outside pointer or focus interaction; dismissal can be prevented

DropdownMenuItem

Prop
Type
Default
Description
tone
'neutral' | 'danger'
'neutral'
Semantic tone
disabled
boolean
false
Whether the item is unavailable
textValue
string
—
Text used for type-ahead matching
class
string
—
Classes appended to the item
Event
Payload
Description
select
event: Event
Emitted when the item is chosen
Slot
Description
default
The item text
icon
Leading icon
trailing
Trailing content

DropdownMenuCheckboxItem

Prop
Type
Default
Description
checked
boolean
false
Whether it is on; supports v-model
disabled
boolean
false
Whether the item is unavailable
textValue
string
—
Text used for type-ahead matching
class
string
—
Classes appended to the item
Slot
Description
default
The item text
trailing
Trailing content

DropdownMenuGroup

Takes only class; its default slot holds the items of one group.

DropdownMenuSub

Prop
Type
Default
Description
open
boolean
—
Whether the submenu is open; supports v-model
label
string
—
Text of the parent item
disabled
boolean
false
Whether the parent item is unavailable
textValue
string
—
Text used for type-ahead matching
class
string
—
Classes appended to the submenu panel
Slot
Description
default
The items of the submenu
label
Text of the parent item, overrides label
icon
Leading icon of the parent item

DropdownMenuRadioGroup

Prop
Type
Default
Description
modelValue
string
—
The selected value; supports v-model

DropdownMenuRadioItem

Prop
Type
Default
Description
value
string
—
Required. The value of this item
disabled
boolean
false
Whether the item is unavailable
textValue
string
—
Text used for type-ahead matching
class
string
—
Classes appended to the item

DropdownMenuLabel and DropdownMenuSeparator

Both take only class. The default slot of DropdownMenuLabel is the heading text; DropdownMenuSeparator has no content.