Dropdown menu
A menu of actions with arrow-key navigation, type-to-select, checkbox and radio items, and submenus.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add dropdown-menupnpm dlx ermineui add dropdown-menuyarn dlx ermineui add dropdown-menubunx ermineui add dropdown-menuThat copies 16 files into src/components/ui/dropdown-menu/, 5 helpers into src/lib/ermine/. Files you already have are kept.
Use
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'<script setup lang="ts">
import { DropdownMenu } from '@/components/ui/dropdown-menu'
</script>
<template>
<DropdownMenu />
</template>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.
What gets copied
| File | Where it goes |
|---|---|
DropdownMenu.vue | src/components/ui/dropdown-menu/DropdownMenu.vue |
DropdownMenuCheckboxItem.vue | src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue |
DropdownMenuContent.vue | src/components/ui/dropdown-menu/DropdownMenuContent.vue |
DropdownMenuGroup.vue | src/components/ui/dropdown-menu/DropdownMenuGroup.vue |
DropdownMenuItem.vue | src/components/ui/dropdown-menu/DropdownMenuItem.vue |
DropdownMenuLabel.vue | src/components/ui/dropdown-menu/DropdownMenuLabel.vue |
DropdownMenuRadioGroup.vue | src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue |
DropdownMenuRadioItem.vue | src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue |
DropdownMenuSeparator.vue | src/components/ui/dropdown-menu/DropdownMenuSeparator.vue |
DropdownMenuShortcut.vue | src/components/ui/dropdown-menu/DropdownMenuShortcut.vue |
DropdownMenuSub.vue | src/components/ui/dropdown-menu/DropdownMenuSub.vue |
DropdownMenuSubContent.vue | src/components/ui/dropdown-menu/DropdownMenuSubContent.vue |
DropdownMenuSubTrigger.vue | src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue |
DropdownMenuTrigger.vue | src/components/ui/dropdown-menu/DropdownMenuTrigger.vue |
context.ts | src/components/ui/dropdown-menu/context.ts |
index.ts | src/components/ui/dropdown-menu/index.ts |
collection.ts helper | src/lib/ermine/collection.ts |
context.ts helper | src/lib/ermine/context.ts |
floating.ts helper | src/lib/ermine/floating.ts |
layer.ts helper | src/lib/ermine/layer.ts |
primitive.ts helper | src/lib/ermine/primitive.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
DropdownMenu.vue 24 lines
<script setup lang="ts">
import { ref, useId } from 'vue'
import { provideDropdownMenu } from './context'
const open = defineModel<boolean>('open', { default: false })
function setOpen(next: boolean) {
open.value = next
}
provideDropdownMenu({
open,
setOpen,
anchor: ref(null),
focusFirst: ref(false),
triggerId: useId(),
contentId: useId(),
})
</script>
<template>
<slot :open="open" :close="() => setOpen(false)" />
</template>DropdownMenuCheckboxItem.vue 46 lines
<script setup lang="ts">
import { onItemKeydown, onItemPointerLeave, onItemPointerMove, useDropdownMenu } from './context'
const props = defineProps<{
disabled?: boolean
/** Close the menu after toggling. Off by default, so several boxes can be changed in one go. */
closeOnSelect?: boolean
}>()
const emit = defineEmits<{
select: [event: Event]
}>()
const checked = defineModel<boolean>({ default: false })
const menu = useDropdownMenu()
function onClick(event: MouseEvent) {
if (props.disabled) return
checked.value = !checked.value
emit('select', event)
if (props.closeOnSelect) menu.setOpen(false)
}
</script>
<template>
<div
role="menuitemcheckbox"
tabindex="-1"
class="er-menu-item"
:aria-checked="checked"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
@click="onClick"
@keydown="onItemKeydown"
@pointermove="onItemPointerMove"
@pointerleave="onItemPointerLeave"
>
<span class="er-menu-indicator">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
<slot />
</div>
</template>DropdownMenuContent.vue 176 lines
<script setup lang="ts">
import { ref } from 'vue'
import { arrowTarget, createTypeahead } from '../../lib/collection'
import { useFloating, type Align, type Side } from '../../lib/floating'
import { useLayer } from '../../lib/layer'
import { Portal } from '../../lib/primitive'
import { useDropdownMenu } from './context'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
side?: Side
align?: Align
/** Gap between trigger and menu, in px. */
sideOffset?: number
alignOffset?: number
}>(),
{ side: 'bottom', align: 'start', sideOffset: 4, alignOffset: 0 },
)
const menu = useDropdownMenu()
const content = ref<HTMLElement | null>(null)
const typeahead = createTypeahead()
function items(): HTMLElement[] {
return Array.from(content.value?.querySelectorAll<HTMLElement>('[role^="menuitem"]:not([data-disabled])') ?? [])
}
useFloating(menu.anchor, content, menu.open, props)
useLayer(content, menu.open, {
ignore: () => [menu.anchor.value],
onDismiss: () => menu.setOpen(false),
initialFocus: () => (menu.focusFirst.value ? items()[0] : null) ?? content.value,
})
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Tab') {
event.preventDefault()
return
}
const list = items()
const target = arrowTarget(event, list, 'vertical') ?? typeahead(event, list)
if (!target) return
event.preventDefault()
target.focus()
}
</script>
<template>
<Portal>
<Transition name="er-pop">
<div
v-if="menu.open.value"
:id="menu.contentId"
ref="content"
v-bind="$attrs"
role="menu"
tabindex="-1"
class="er-floating er-menu"
:aria-labelledby="menu.anchor.value?.id || undefined"
@keydown="onKeydown"
>
<slot />
</div>
</Transition>
</Portal>
</template>
<style>
@layer components {
.er-menu {
min-width: 10rem;
max-height: min(24rem, var(--er-available-size, 24rem));
padding: 0.25rem;
border: 1px solid var(--er-border);
border-radius: var(--er-radius-md);
background: var(--er-popover);
color: var(--er-popover-foreground);
font-size: 0.875rem;
box-shadow: var(--er-shadow-md);
overflow-y: auto;
}
.er-menu-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: var(--er-radius-sm);
color: inherit;
line-height: 1.25rem;
text-decoration: none;
cursor: default;
user-select: none;
outline: none;
}
.er-menu-item:focus {
background: var(--er-accent);
color: var(--er-accent-foreground);
}
.er-menu-item[data-variant='destructive'] {
color: var(--er-destructive);
}
.er-menu-item[data-variant='destructive']:focus {
background: color-mix(in oklab, var(--er-destructive) 12%, transparent);
color: var(--er-destructive);
}
.er-menu-item[data-disabled] {
opacity: 0.5;
pointer-events: none;
}
.er-menu-item svg {
flex-shrink: 0;
width: 1rem;
height: 1rem;
color: var(--er-muted-foreground);
}
.er-menu-item[data-variant='destructive'] svg {
color: inherit;
}
/* Checkbox and radio items keep a fixed slot for their mark, so labels line up whether or not it shows. */
.er-menu-indicator {
display: inline-grid;
flex-shrink: 0;
place-items: center;
width: 1rem;
height: 1rem;
}
.er-menu-indicator svg {
color: currentColor;
}
.er-menu-item[aria-checked='false'] .er-menu-indicator svg {
visibility: hidden;
}
.er-menu-item[aria-haspopup='menu'][data-state='open'] {
background: var(--er-accent);
color: var(--er-accent-foreground);
}
.er-menu-chevron {
margin-left: auto;
}
.er-menu-shortcut {
margin-left: auto;
padding-left: 1.5rem;
color: var(--er-muted-foreground);
font-size: 0.75rem;
letter-spacing: 0.08em;
}
.er-menu-label {
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
font-weight: var(--er-weight-semibold);
}
.er-menu-separator {
height: 1px;
margin: 0.25rem -0.25rem;
background: var(--er-border);
}
}
</style>DropdownMenuGroup.vue 5 lines
<template>
<!-- Give it an aria-label (or aria-labelledby pointing at a label) so screen readers name the group. -->
<div role="group" class="er-menu-group"><slot /></div>
</template>DropdownMenuItem.vue 64 lines
<script setup lang="ts">
import type { Component } from 'vue'
import { Primitive } from '../../lib/primitive'
import { useDropdownMenu } from './context'
const props = withDefaults(
defineProps<{
/** Tag or component to render — use "a" (or a router link) for navigation items. */
as?: string | Component
disabled?: boolean
variant?: 'default' | 'destructive'
}>(),
{ as: 'div', variant: 'default' },
)
const emit = defineEmits<{
/** Fired when the item is chosen. Call event.preventDefault() to keep the menu open. */
select: [event: Event]
}>()
const menu = useDropdownMenu()
function onClick(event: MouseEvent) {
if (props.disabled) {
event.preventDefault()
return
}
emit('select', event)
if (!event.defaultPrevented) menu.setOpen(false)
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
;(event.currentTarget as HTMLElement).click()
}
function onPointerMove(event: PointerEvent) {
if (!props.disabled) (event.currentTarget as HTMLElement).focus({ preventScroll: true })
}
function onPointerLeave(event: PointerEvent) {
;(event.currentTarget as HTMLElement).closest<HTMLElement>('[role="menu"]')?.focus({ preventScroll: true })
}
</script>
<template>
<Primitive
:as="as"
role="menuitem"
tabindex="-1"
class="er-menu-item"
:data-variant="variant"
:data-disabled="disabled ? '' : undefined"
:aria-disabled="disabled || undefined"
@click="onClick"
@keydown="onKeydown"
@pointermove="onPointerMove"
@pointerleave="onPointerLeave"
>
<slot />
</Primitive>
</template>DropdownMenuLabel.vue 4 lines
<template>
<div class="er-menu-label"><slot /></div>
</template>DropdownMenuRadioGroup.vue 17 lines
<script setup lang="ts">
import { provideDropdownMenuRadio } from './context'
const value = defineModel<string>()
provideDropdownMenuRadio({
value,
select: (next) => {
value.value = next
},
})
</script>
<template>
<div role="group" class="er-menu-group"><slot /></div>
</template>DropdownMenuRadioItem.vue 49 lines
<script setup lang="ts">
import { computed } from 'vue'
import { onItemKeydown, onItemPointerLeave, onItemPointerMove, useDropdownMenu, useDropdownMenuRadio } from './context'
const props = defineProps<{
value: string
disabled?: boolean
/** Close the menu after choosing. Off by default, like the checkbox item. */
closeOnSelect?: boolean
}>()
const emit = defineEmits<{
select: [event: Event]
}>()
const menu = useDropdownMenu()
const group = useDropdownMenuRadio()
const checked = computed(() => group.value.value === props.value)
function onClick(event: MouseEvent) {
if (props.disabled) return
group.select(props.value)
emit('select', event)
if (props.closeOnSelect) menu.setOpen(false)
}
</script>
<template>
<div
role="menuitemradio"
tabindex="-1"
class="er-menu-item"
:aria-checked="checked"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
@click="onClick"
@keydown="onItemKeydown"
@pointermove="onItemPointerMove"
@pointerleave="onItemPointerLeave"
>
<span class="er-menu-indicator">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="12" r="5" />
</svg>
</span>
<slot />
</div>
</template>DropdownMenuSeparator.vue 4 lines
<template>
<div role="separator" class="er-menu-separator" />
</template>DropdownMenuShortcut.vue 4 lines
<template>
<span class="er-menu-shortcut" aria-hidden="true"><slot /></span>
</template>DropdownMenuSub.vue 51 lines
<script setup lang="ts">
import { onBeforeUnmount, ref, useId, watch } from 'vue'
import { provideDropdownMenuSub, useDropdownMenu } from './context'
const menu = useDropdownMenu()
const open = ref(false)
const focusFirst = ref(false)
const anchor = ref<HTMLElement | null>(null)
const content = ref<HTMLElement | null>(null)
let timer: ReturnType<typeof setTimeout> | undefined
function cancelClose() {
clearTimeout(timer)
}
function setOpen(next: boolean, first = false) {
cancelClose()
focusFirst.value = next && first
open.value = next
}
provideDropdownMenuSub({
open,
setOpen,
focusFirst,
anchor,
content,
contentId: useId(),
// The short wait lets the pointer travel diagonally from the trigger into the submenu without it closing.
scheduleClose: () => {
cancelClose()
timer = setTimeout(() => setOpen(false), 150)
},
cancelClose,
})
// When the whole menu closes, this closes with it. Focus steps back into the parent menu first,
// so the menu can hand it back to the button that opened it.
watch(menu.open, (isOpen) => {
if (isOpen || !open.value) return
if (content.value?.contains(document.activeElement)) anchor.value?.focus({ preventScroll: true })
setOpen(false)
})
onBeforeUnmount(cancelClose)
</script>
<template>
<slot :open="open" />
</template>DropdownMenuSubContent.vue 79 lines
<script setup lang="ts">
import { computed } from 'vue'
import { arrowTarget, createTypeahead } from '../../lib/collection'
import { useFloating } from '../../lib/floating'
import { useLayer } from '../../lib/layer'
import { Portal } from '../../lib/primitive'
import { menuItems, useDropdownMenu, useDropdownMenuSub } from './context'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
/** Gap between the parent menu and this one, in px. */
sideOffset?: number
/** Lifts the submenu so its first item lines up with the item that opened it. */
alignOffset?: number
}>(),
{ sideOffset: 2, alignOffset: -5 },
)
const menu = useDropdownMenu()
const sub = useDropdownMenuSub()
const typeahead = createTypeahead()
const active = computed(() => sub.open.value && menu.open.value)
function closeToTrigger() {
sub.setOpen(false)
sub.anchor.value?.focus({ preventScroll: true })
}
useFloating(sub.anchor, sub.content, active, { side: 'right', align: 'start', sideOffset: props.sideOffset, alignOffset: props.alignOffset })
useLayer(sub.content, active, {
ignore: () => [sub.anchor.value],
// Escape steps back one level; a press elsewhere just closes this level and lets the press through.
onDismiss: (reason) => (reason === 'escape' ? closeToTrigger() : sub.setOpen(false)),
// Opened by hovering, focus stays on the trigger; opened from the keyboard, it moves to the first item.
initialFocus: () => (sub.focusFirst.value ? (menuItems(sub.content.value)[0] ?? sub.content.value) : false),
restoreFocus: false,
})
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Tab') {
event.preventDefault()
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
closeToTrigger()
return
}
const list = menuItems(sub.content.value)
const target = arrowTarget(event, list, 'vertical') ?? typeahead(event, list)
if (!target) return
event.preventDefault()
target.focus()
}
</script>
<template>
<Portal>
<Transition name="er-pop">
<div
v-if="active"
:id="sub.contentId"
:ref="(el) => (sub.content.value = el as HTMLElement | null)"
v-bind="$attrs"
role="menu"
tabindex="-1"
class="er-floating er-menu"
@keydown="onKeydown"
@pointerenter="sub.cancelClose()"
@focusin="sub.cancelClose()"
>
<slot />
</div>
</Transition>
</Portal>
</template>DropdownMenuSubTrigger.vue 55 lines
<script setup lang="ts">
import { menuItems, useDropdownMenuSub } from './context'
const props = defineProps<{
disabled?: boolean
}>()
const sub = useDropdownMenuSub()
function onPointerMove(event: PointerEvent) {
if (props.disabled) return
;(event.currentTarget as HTMLElement).focus({ preventScroll: true })
if (sub.open.value) sub.cancelClose()
else sub.setOpen(true)
}
function onKeydown(event: KeyboardEvent) {
if (props.disabled || (event.key !== 'ArrowRight' && event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
// Already open from hovering: the keyboard just steps inside.
if (sub.open.value) menuItems(sub.content.value)[0]?.focus()
else sub.setOpen(true, true)
}
function onBlur(event: FocusEvent) {
const next = event.relatedTarget
if (next instanceof Node && sub.content.value?.contains(next)) return
sub.scheduleClose()
}
</script>
<template>
<div
:ref="(el) => (sub.anchor.value = el as HTMLElement | null)"
role="menuitem"
tabindex="-1"
class="er-menu-item"
aria-haspopup="menu"
:aria-expanded="sub.open.value"
:aria-controls="sub.open.value ? sub.contentId : undefined"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
:data-state="sub.open.value ? 'open' : 'closed'"
@click="!disabled && sub.setOpen(true)"
@keydown="onKeydown"
@pointermove="onPointerMove"
@blur="onBlur"
>
<slot />
<svg class="er-menu-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</div>
</template>DropdownMenuTrigger.vue 52 lines
<script setup lang="ts">
import type { Component } from 'vue'
import { Primitive, toElement } from '../../lib/primitive'
import { useDropdownMenu } from './context'
withDefaults(
defineProps<{
as?: string | Component
/** Use the single child (e.g. a Button) as the trigger instead of rendering one. */
asChild?: boolean
}>(),
{ as: 'button' },
)
const menu = useDropdownMenu()
function setAnchor(target: unknown) {
menu.anchor.value = toElement(target)
}
function onClick(event: MouseEvent) {
// detail is 0 when the click came from Enter or Space rather than a pointer.
menu.focusFirst.value = event.detail === 0
menu.setOpen(!menu.open.value)
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowDown' || menu.open.value) return
event.preventDefault()
menu.focusFirst.value = true
menu.setOpen(true)
}
</script>
<template>
<Primitive
:id="menu.triggerId"
:ref="setAnchor"
:as="as"
:as-child="asChild"
:type="as === 'button' && !asChild ? 'button' : undefined"
aria-haspopup="menu"
:aria-expanded="menu.open.value"
:aria-controls="menu.open.value ? menu.contentId : undefined"
:data-state="menu.open.value ? 'open' : 'closed'"
@click="onClick"
@keydown="onKeydown"
>
<slot />
</Primitive>
</template>context.ts 59 lines
import type { Ref } from 'vue'
import { createContext } from '../../lib/context'
export interface DropdownMenuContext {
open: Ref<boolean>
setOpen: (open: boolean) => void
anchor: Ref<HTMLElement | null>
/** True when the menu was opened from the keyboard, so focus goes to the first item. */
focusFirst: Ref<boolean>
triggerId: string
contentId: string
}
export const [provideDropdownMenu, useDropdownMenu] = createContext<DropdownMenuContext>('DropdownMenu')
export interface DropdownMenuRadioContext {
value: Ref<string | undefined>
select: (value: string) => void
}
export const [provideDropdownMenuRadio, useDropdownMenuRadio] = createContext<DropdownMenuRadioContext>('DropdownMenuRadioGroup')
export interface DropdownMenuSubContext {
open: Ref<boolean>
/** Open or close the submenu. `focusFirst` moves focus to its first item, as the keyboard expects. */
setOpen: (open: boolean, focusFirst?: boolean) => void
focusFirst: Ref<boolean>
anchor: Ref<HTMLElement | null>
content: Ref<HTMLElement | null>
contentId: string
/** Close shortly, unless the pointer arrives in the submenu first. */
scheduleClose: () => void
cancelClose: () => void
}
export const [provideDropdownMenuSub, useDropdownMenuSub] = createContext<DropdownMenuSubContext>('DropdownMenuSub')
/** Items of one menu that can take focus, in order. */
export function menuItems(menu: HTMLElement | null | undefined): HTMLElement[] {
return Array.from(menu?.querySelectorAll<HTMLElement>('[role^="menuitem"]:not([data-disabled])') ?? [])
}
/** Enter and Space activate the focused item, the same as a click. */
export function onItemKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
;(event.currentTarget as HTMLElement).click()
}
/** The item under the pointer takes focus, so hover and keyboard highlight are one and the same. */
export function onItemPointerMove(event: PointerEvent) {
const item = event.currentTarget as HTMLElement
if (!item.hasAttribute('data-disabled')) item.focus({ preventScroll: true })
}
export function onItemPointerLeave(event: PointerEvent) {
;(event.currentTarget as HTMLElement).closest<HTMLElement>('[role="menu"]')?.focus({ preventScroll: true })
}index.ts 16 lines
/** Dropdown menu — a menu of actions with arrow-key navigation, type-to-select, checkbox and radio items, and submenus. */
export { default as DropdownMenu } from './DropdownMenu.vue'
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue'
export { default as DropdownMenuSub } from './DropdownMenuSub.vue'
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue'
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue'
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'