<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>
Usage
import { DropdownMenu, DropdownMenuItem } from '@hina-ui/vue'
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Modality and focus
modal defaults to true, locking page scrolling and restricting outside interaction while open. With :modal="false", the page remains scrollable and interactive. Clicking outside still closes the menu.
Without a default trigger, closing restores the element focused before opening; the anchor is only used for positioning. If the caller has already moved focus outside, the previous element is not focused again. Call event.preventDefault() in closeAutoFocus to cancel focus restoration, or in interactOutside and escapeKeyDown to prevent the corresponding dismissal.
Use label or aria-label to name the menu. aria-describedby and data-* attributes are forwarded to the menu panel.
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>
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 arole="menu"and items arerole="menuitem". - Give the menu a name through
label; screen readers announce it on entry. - Radio items render as
menuitemradioand checkbox items asmenuitemcheckbox, both witharia-checked. - The parent item of a submenu carries
aria-haspopup="menu"andaria-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 |
| Event | Argument | Description |
|---|---|---|
closeAutoFocus | Event | Before restoring focus on close; can be prevented |
escapeKeyDown | KeyboardEvent | Escape key; dismissal can be prevented |
pointerDownOutside | PointerDownOutsideEvent | Outside pointer down; dismissal can be prevented |
focusOutside | FocusOutsideEvent | Focus moves outside; dismissal can be prevented |
interactOutside | PointerDownOutsideEvent | 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.