Context Menu
A right-click / long-press menu with a gliding highlight, shortcuts and a submenu that slides out.
Right-click or long-press here
vue
<script setup lang="ts">
import type { ContextMenuAction, ContextMenuEntry } from '@/components/ui/context-menu'
import { ContextMenu } from '@/components/ui/context-menu'
import { Copy, FolderOpen, History, Link, Mail, MessageSquare, MousePointerClick, Pencil, Share2, Star, Trash2 } from 'lucide-vue-next'
import { ref } from 'vue'
const items: ContextMenuEntry[] = [
{ type: 'label', label: 'aurora-q3.fig' },
{ label: 'Open', icon: FolderOpen, shortcut: '↵' },
{ label: 'Rename', icon: Pencil, shortcut: 'F2' },
{ label: 'Duplicate', icon: Copy, shortcut: '⌘D' },
{ label: 'Add to favorites', icon: Star },
{
label: 'Share',
icon: Share2,
children: [
{ label: 'Copy link', icon: Link, shortcut: '⌘L' },
{ label: 'Send by email', icon: Mail },
{ label: 'Post to channel', icon: MessageSquare },
],
},
{ label: 'Version history', icon: History, disabled: true },
{ type: 'separator' },
{ label: 'Move to trash', icon: Trash2, shortcut: '⌫', destructive: true },
]
const last = ref<string>()
function onSelect(item: ContextMenuAction) {
last.value = item.label
}
</script>
<template>
<div class="flex w-full max-w-md flex-col items-center gap-3">
<ContextMenu
:items="items"
label="File actions"
content-class="vp-raw"
class="flex h-44 w-full flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed bg-muted/40 text-sm text-muted-foreground"
@select="onSelect"
>
<MousePointerClick class="size-6" />
<span>Right-click or long-press here</span>
</ContextMenu>
<p class="m-0 h-5 text-xs text-muted-foreground">
<template v-if="last">
Selected: <span class="font-medium text-foreground">{{ last }}</span>
</template>
</p>
</div>
</template>Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/context-menu.jsonManual installation
Install the dependencies:
bash
npm install motion-vThen copy these files into components/ui/context-menu/ (plus lib/utils.ts if you don't have it):
vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ContextMenuAction, ContextMenuEntry } from './types'
import { AnimatePresence, motion, useDomRef, useReducedMotion } from 'motion-v'
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
import { cn } from '@/lib/utils'
import ContextMenuList from './ContextMenuList.vue'
import { isAction } from './types'
const props = withDefaults(defineProps<{
/** Menu entries: actions, separators, labels; actions may have `children`. */
items: ContextMenuEntry[]
/** Hold time (ms) that opens the menu on touch / pen. */
longPress?: number
/** Disable the menu (the native one shows instead). */
disabled?: boolean
/** Accessible name for the menu. */
label?: string
/** Classes for the right-clickable area. */
class?: HTMLAttributes['class']
/** Classes for the floating menu surfaces. */
contentClass?: HTMLAttributes['class']
}>(), {
longPress: 500,
label: 'Actions',
})
const emit = defineEmits<{
select: [item: ContextMenuAction]
'update:open': [open: boolean]
}>()
const uid = useId()
const reduced = useReducedMotion()
const area = ref<HTMLElement | null>(null)
const rootEl = useDomRef()
const subEl = useDomRef()
const pop = { type: 'spring', stiffness: 600, damping: 34, mass: 0.7 } as const
const slide = { type: 'spring', stiffness: 520, damping: 36 } as const
const PAD = 8
const open = ref(false)
const root = ref({ x: 0, y: 0, origin: 'top left', active: -1 })
const sub = ref({ index: -1, active: -1, x: 0, y: 0, flipped: false })
const subEntries = computed(() => {
const parent = props.items[sub.value.index]
return isAction(parent) ? parent.children ?? [] : []
})
let restoreFocus: HTMLElement | null = null
let suppressNativeUntil = 0
let openedAt = 0
let subTimer: ReturnType<typeof setTimeout> | undefined
let pressTimer: ReturnType<typeof setTimeout> | undefined
let pressStart = { x: 0, y: 0 }
let focusSubOnMount = false
// --- opening / closing ----------------------------------------------------
async function openAt(x: number, y: number, viaKeyboard = false) {
if (props.disabled)
return
openedAt = Date.now()
restoreFocus = (document.activeElement as HTMLElement | null) ?? area.value
if (restoreFocus === document.body)
restoreFocus = area.value
sub.value = { index: -1, active: -1, x: 0, y: 0, flipped: false }
root.value = { x, y, origin: 'top left', active: -1 }
if (!open.value) {
open.value = true
emit('update:open', true)
bind(true)
}
await nextTick()
placeRoot(x, y)
if (viaKeyboard)
moveRoot(0, 1)
else
(rootEl.value as HTMLElement | null)?.focus({ preventScroll: true })
}
function close(returnFocus = true) {
if (!open.value)
return
clearTimeout(subTimer)
open.value = false
emit('update:open', false)
bind(false)
if (returnFocus)
restoreFocus?.focus({ preventScroll: true })
restoreFocus = null
}
function placeRoot(x: number, y: number) {
const el = rootEl.value as HTMLElement | null
if (!el)
return
const w = el.offsetWidth
const h = el.offsetHeight
const vw = document.documentElement.clientWidth
const vh = window.innerHeight
// Open towards the side with room, like native menus: flip at the edges, then clamp.
const flipX = x + w > vw - PAD
const flipY = y + h > vh - PAD
const left = Math.min(Math.max(flipX ? x - w : x, PAD), vw - w - PAD)
const top = Math.min(Math.max(flipY ? y - h : y, PAD), vh - h - PAD)
root.value = { ...root.value, x: left, y: top, origin: `${flipY ? 'bottom' : 'top'} ${flipX ? 'right' : 'left'}` }
}
// --- submenu ---------------------------------------------------------------
function openSub(index: number, focusFirst = false) {
clearTimeout(subTimer)
if (sub.value.index === index) {
if (focusFirst)
moveSub(0, 1)
return
}
const rootRect = (rootEl.value as HTMLElement | null)?.getBoundingClientRect()
// Guess the side before mount so the slide starts in the right direction.
const flipped = !!rootRect && rootRect.right + 200 > document.documentElement.clientWidth - PAD
sub.value = { index, active: -1, x: rootRect?.right ?? 0, y: rootRect?.top ?? 0, flipped }
focusSubOnMount = focusFirst
}
function keepSub() {
clearTimeout(subTimer)
}
// The submenu mounts after the previous one has exited (mode="wait"),
// so measure and focus it once its element exists.
watch(() => subEl.value as HTMLElement | null, (el) => {
if (!el)
return
placeSub()
if (focusSubOnMount)
moveSub(0, 1)
focusSubOnMount = false
}, { flush: 'post' })
function onRootPointerleave() {
if (sub.value.index === -1)
root.value.active = -1
}
function closeSub() {
clearTimeout(subTimer)
sub.value = { ...sub.value, index: -1, active: -1 }
}
function placeSub() {
const menu = rootEl.value as HTMLElement | null
const el = subEl.value as HTMLElement | null
const row = menu?.querySelector<HTMLElement>(`[data-index="${sub.value.index}"]`)
if (!menu || !el || !row)
return
const m = menu.getBoundingClientRect()
const r = row.getBoundingClientRect()
const w = el.offsetWidth
const h = el.offsetHeight
const vw = document.documentElement.clientWidth
const vh = window.innerHeight
const flipped = m.right - 4 + w > vw - PAD
const x = flipped ? Math.max(m.left - w + 4, PAD) : m.right - 4
// Line the first row of the submenu up with its parent row (p-1 + border = 5px).
const y = Math.min(Math.max(r.top - 5, PAD), vh - h - PAD)
sub.value = { ...sub.value, x, y, flipped }
}
// --- focus + highlight -----------------------------------------------------
function focusRow(menu: HTMLElement | null, index: number) {
menu?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.focus({ preventScroll: true })
}
function step(entries: ContextMenuEntry[], from: number, dir: 1 | -1) {
const n = entries.length
for (let k = 1; k <= n; k++) {
const i = (((from + dir * k) % n) + n) % n
const e = entries[i]
if (isAction(e) && !e.disabled)
return i
}
return -1
}
function moveRoot(from: number, dir: 1 | -1) {
const i = step(props.items, from - dir, dir)
root.value.active = i
focusRow(rootEl.value as HTMLElement | null, i)
}
function moveSub(from: number, dir: 1 | -1) {
const i = step(subEntries.value, from - dir, dir)
sub.value.active = i
focusRow(subEl.value as HTMLElement | null, i)
}
function hoverRoot(index: number) {
if (root.value.active === index)
return
const entry = props.items[index]
const usable = isAction(entry) && !entry.disabled
root.value.active = usable ? index : -1
if (usable)
focusRow(rootEl.value as HTMLElement | null, index)
clearTimeout(subTimer)
if (usable && entry.children?.length)
subTimer = setTimeout(() => openSub(index), 90)
else if (sub.value.index !== -1)
subTimer = setTimeout(closeSub, 260) // grace period to travel into the submenu
}
function hoverSub(index: number) {
clearTimeout(subTimer)
if (sub.value.active === index)
return
const entry = subEntries.value[index]
const usable = isAction(entry) && !entry.disabled
sub.value.active = usable ? index : -1
root.value.active = -1
if (usable)
focusRow(subEl.value as HTMLElement | null, index)
}
function activate(entries: ContextMenuEntry[], index: number, level: 0 | 1) {
const entry = entries[index]
if (!isAction(entry) || entry.disabled)
return
if (entry.children?.length) {
if (level === 0)
openSub(index, true)
return
}
close()
entry.onSelect?.()
emit('select', entry)
}
// --- typeahead -------------------------------------------------------------
let typed = ''
let typedTimer: ReturnType<typeof setTimeout> | undefined
function typeahead(key: string, entries: ContextMenuEntry[], current: number) {
clearTimeout(typedTimer)
typed += key.toLowerCase()
typedTimer = setTimeout(() => (typed = ''), 600)
const n = entries.length
for (let k = 1; k <= n; k++) {
const i = (current + k) % n
const e = entries[i]
if (isAction(e) && !e.disabled && e.label.toLowerCase().startsWith(typed))
return i
}
return -1
}
// --- keyboard --------------------------------------------------------------
function onRootKeydown(e: KeyboardEvent) {
const { active } = root.value
switch (e.key) {
case 'ArrowDown': moveRoot(active + 1, 1); break
case 'ArrowUp': moveRoot(active === -1 ? props.items.length - 1 : active - 1, -1); break
case 'Home': moveRoot(0, 1); break
case 'End': moveRoot(props.items.length - 1, -1); break
case 'ArrowRight': {
const entry = props.items[active]
if (isAction(entry) && entry.children?.length && !entry.disabled)
openSub(active, true)
break
}
case 'Enter':
case ' ':
if (active !== -1)
activate(props.items, active, 0)
break
case 'Escape': close(); break
case 'Tab': close(); break
default:
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
const i = typeahead(e.key, props.items, active)
if (i !== -1) {
root.value.active = i
focusRow(rootEl.value as HTMLElement | null, i)
}
}
return
}
e.preventDefault()
e.stopPropagation()
}
function onSubKeydown(e: KeyboardEvent) {
const { active } = sub.value
const n = subEntries.value.length
switch (e.key) {
case 'ArrowDown': moveSub(active + 1, 1); break
case 'ArrowUp': moveSub(active === -1 ? n - 1 : active - 1, -1); break
case 'Home': moveSub(0, 1); break
case 'End': moveSub(n - 1, -1); break
case 'ArrowLeft':
case 'Escape': {
const parent = sub.value.index
closeSub()
root.value.active = parent
focusRow(rootEl.value as HTMLElement | null, parent)
break
}
case 'Enter':
case ' ':
if (active !== -1)
activate(subEntries.value, active, 1)
break
case 'Tab': close(); break
default:
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
const i = typeahead(e.key, subEntries.value, active)
if (i !== -1) {
sub.value.active = i
focusRow(subEl.value as HTMLElement | null, i)
}
}
return
}
e.preventDefault()
e.stopPropagation()
}
// --- triggers --------------------------------------------------------------
function onContextmenu(e: MouseEvent) {
if (props.disabled)
return
e.preventDefault()
if (Date.now() < suppressNativeUntil)
return
openAt(e.clientX, e.clientY)
}
function onAreaKeydown(e: KeyboardEvent) {
if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {
e.preventDefault()
const r = area.value!.getBoundingClientRect()
suppressNativeUntil = Date.now() + 400
openAt(r.left + Math.min(24, r.width / 2), r.top + Math.min(24, r.height / 2), true)
}
}
function cancelPress() {
clearTimeout(pressTimer)
}
function onPointerdown(e: PointerEvent) {
if (e.pointerType === 'mouse' || props.disabled)
return
pressStart = { x: e.clientX, y: e.clientY }
cancelPress()
pressTimer = setTimeout(() => {
// Some browsers also fire `contextmenu` for a long-press — ignore that one.
suppressNativeUntil = Date.now() + 800
openAt(pressStart.x, pressStart.y)
}, props.longPress)
}
function onPointermove(e: PointerEvent) {
if (Math.hypot(e.clientX - pressStart.x, e.clientY - pressStart.y) > 10)
cancelPress()
}
// --- global listeners while open -------------------------------------------
function onDocumentPointerdown(e: PointerEvent) {
const t = e.target as Node
if ((rootEl.value as HTMLElement | null)?.contains(t) || (subEl.value as HTMLElement | null)?.contains(t))
return
close(false)
}
function dismiss() {
// Right-clicking focuses the area, which may scroll it into view — not a dismiss.
if (Date.now() - openedAt < 250)
return
close(false)
}
function bind(on: boolean) {
if (typeof document === 'undefined')
return
if (on) {
document.addEventListener('pointerdown', onDocumentPointerdown, true)
window.addEventListener('resize', dismiss)
window.addEventListener('blur', dismiss)
window.addEventListener('scroll', dismiss, true)
}
else {
document.removeEventListener('pointerdown', onDocumentPointerdown, true)
window.removeEventListener('resize', dismiss)
window.removeEventListener('blur', dismiss)
window.removeEventListener('scroll', dismiss, true)
}
}
onBeforeUnmount(() => {
clearTimeout(subTimer)
clearTimeout(pressTimer)
clearTimeout(typedTimer)
bind(false)
})
defineExpose({ openAt, close })
</script>
<template>
<div
ref="area"
tabindex="0"
:class="cn(
'select-none outline-none [-webkit-touch-callout:none] focus-visible:ring-[3px] focus-visible:ring-ring/50',
props.class,
)"
@contextmenu="onContextmenu"
@keydown="onAreaKeydown"
@pointerdown="onPointerdown"
@pointermove="onPointermove"
@pointerup="cancelPress"
@pointercancel="cancelPress"
@pointerleave="cancelPress"
>
<slot />
</div>
<Teleport to="body">
<AnimatePresence>
<motion.div
v-if="open"
ref="rootEl"
key="root"
role="menu"
:aria-label="props.label"
aria-orientation="vertical"
tabindex="-1"
:class="cn(
'fixed z-[96] min-w-52 rounded-xl border bg-popover p-1 text-popover-foreground shadow-xl outline-none',
props.contentClass,
)"
:style="{ left: `${root.x}px`, top: `${root.y}px`, transformOrigin: root.origin }"
:initial="reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9 }"
:animate="{ opacity: 1, scale: 1 }"
:exit="{ opacity: 0, scale: reduced ? 1 : 0.96, transition: { duration: 0.12 } }"
:transition="pop"
@keydown="onRootKeydown"
@pointerleave="onRootPointerleave"
@contextmenu.prevent
>
<ContextMenuList
:entries="props.items"
:highlighted="root.active !== -1 ? root.active : sub.index"
:expanded="sub.index"
:layout-id="`vx-ctx-${uid}-root`"
:id-prefix="`${uid}-r`"
:reduced="reduced"
@hover="hoverRoot"
@activate="activate(props.items, $event, 0)"
/>
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
v-if="open && sub.index !== -1"
ref="subEl"
:key="`sub-${sub.index}`"
role="menu"
:aria-label="(props.items[sub.index] as ContextMenuAction | undefined)?.label"
aria-orientation="vertical"
tabindex="-1"
:class="cn(
'fixed z-[97] min-w-44 rounded-xl border bg-popover p-1 text-popover-foreground shadow-xl outline-none',
props.contentClass,
)"
:style="{ left: `${sub.x}px`, top: `${sub.y}px`, transformOrigin: sub.flipped ? 'top right' : 'top left' }"
:initial="reduced ? { opacity: 0 } : { opacity: 0, x: sub.flipped ? 12 : -12, scale: 0.96 }"
:animate="{ opacity: 1, x: 0, scale: 1 }"
:exit="{ opacity: 0, x: reduced ? 0 : sub.flipped ? 8 : -8, transition: { duration: 0.1 } }"
:transition="slide"
@keydown="onSubKeydown"
@pointerenter="keepSub"
@pointerleave="sub.active = -1"
@contextmenu.prevent
>
<ContextMenuList
:entries="subEntries"
:highlighted="sub.active"
:expanded="-1"
:layout-id="`vx-ctx-${uid}-sub`"
:id-prefix="`${uid}-s`"
:reduced="reduced"
@hover="hoverSub"
@activate="activate(subEntries, $event, 1)"
/>
</motion.div>
</AnimatePresence>
</Teleport>
</template>vue
<script setup lang="ts">
import type { ContextMenuEntry } from './types'
import { motion } from 'motion-v'
import { cn } from '@/lib/utils'
import { isAction } from './types'
// Renders the rows of one menu level. All state lives in <ContextMenu>.
const props = defineProps<{
entries: ContextMenuEntry[]
/** Index of the highlighted row (-1 for none). */
highlighted: number
/** Index of the row whose submenu is open (-1 for none). */
expanded: number
/** Shared layout id for the gliding highlight of this level. */
layoutId: string
/** Prefix for row ids. */
idPrefix: string
reduced: boolean
}>()
const emit = defineEmits<{
hover: [index: number]
activate: [index: number]
}>()
const glide = { type: 'spring', stiffness: 700, damping: 45, mass: 0.6 } as const
</script>
<template>
<template v-for="(entry, i) in props.entries" :key="i">
<div
v-if="entry.type === 'separator'"
role="separator"
class="-mx-1 my-1 h-px bg-border"
/>
<div
v-else-if="entry.type === 'label'"
role="presentation"
class="px-2 pb-1 pt-1.5 text-xs font-medium text-muted-foreground"
>
{{ entry.label }}
</div>
<div
v-else-if="isAction(entry)"
:id="`${props.idPrefix}-${i}`"
role="menuitem"
tabindex="-1"
:data-index="i"
:aria-disabled="entry.disabled || undefined"
:aria-haspopup="entry.children?.length ? 'menu' : undefined"
:aria-expanded="entry.children?.length ? props.expanded === i : undefined"
:class="cn(
'relative isolate flex h-8 cursor-default select-none items-center gap-2 rounded-md px-2 text-sm outline-none',
entry.destructive ? 'text-destructive' : 'text-popover-foreground',
entry.disabled && 'opacity-50',
)"
@pointermove="emit('hover', i)"
@click="emit('activate', i)"
>
<motion.span
v-if="props.highlighted === i"
aria-hidden="true"
:layout-id="props.layoutId"
:class="cn('absolute inset-0 -z-10 rounded-md', entry.destructive ? 'bg-destructive/10' : 'bg-accent')"
:transition="props.reduced ? { duration: 0 } : glide"
/>
<component
:is="entry.icon"
v-if="entry.icon"
aria-hidden="true"
:class="cn('size-4 shrink-0', entry.destructive ? 'text-destructive' : 'text-muted-foreground')"
/>
<span class="flex-1 truncate">{{ entry.label }}</span>
<span
v-if="entry.shortcut"
aria-hidden="true"
class="ml-auto pl-6 text-xs tracking-widest text-muted-foreground"
>{{ entry.shortcut }}</span>
<svg
v-if="entry.children?.length"
class="ml-auto size-3.5 text-muted-foreground"
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>
</template>ts
import type { Component } from 'vue'
/** A selectable row. Give it `children` to turn it into a submenu trigger. */
export interface ContextMenuAction {
type?: 'item'
label: string
/** Optional id passed back through `select`. */
value?: string
/** Any component (e.g. a lucide icon). */
icon?: Component
/** Display-only shortcut hint, e.g. `⌘C`. */
shortcut?: string
disabled?: boolean
/** Paint the row in the destructive color. */
destructive?: boolean
/** Nested entries shown in a submenu that slides out to the side. */
children?: ContextMenuEntry[]
onSelect?: () => void
}
export interface ContextMenuSeparator {
type: 'separator'
}
export interface ContextMenuHeading {
type: 'label'
label: string
}
export type ContextMenuEntry = ContextMenuAction | ContextMenuSeparator | ContextMenuHeading
export function isAction(entry: ContextMenuEntry | undefined): entry is ContextMenuAction {
return !!entry && (entry.type === undefined || entry.type === 'item')
}ts
export { default as ContextMenu } from './ContextMenu.vue'
export type { ContextMenuAction, ContextMenuEntry, ContextMenuHeading, ContextMenuSeparator } from './types'Usage
ts
import type { ContextMenuEntry } from '@/components/ui/context-menu'
import { ContextMenu } from '@/components/ui/context-menu'
import { Copy, Share2, Trash2 } from 'lucide-vue-next'
const items: ContextMenuEntry[] = [
{ label: 'Duplicate', icon: Copy, shortcut: '⌘D', onSelect: duplicate },
{ label: 'Share', icon: Share2, children: [{ label: 'Copy link' }, { label: 'Email' }] },
{ type: 'separator' },
{ label: 'Delete', icon: Trash2, destructive: true },
]vue
<ContextMenu :items="items" @select="item => console.log(item.label)">
<div class="h-40 rounded-xl border">Right-click me</div>
</ContextMenu>The menu opens at the pointer (or after a long-press on touch), flips away from the screen edges and is clamped to the viewport. Hovering a row with children slides its submenu out to the side.
API
| Prop | Type | Default | Description |
|---|---|---|---|
items | ContextMenuEntry[] | — | Actions, { type: 'separator' } and { type: 'label', label } headings. |
longPress | number | 500 | Hold time (ms) that opens the menu on touch / pen. |
disabled | boolean | false | Turn the custom menu off. |
label | string | 'Actions' | Accessible name of the menu. |
class | string | — | Classes for the right-clickable area. |
contentClass | string | — | Classes for the floating menu surfaces. |
| Action field | Type | Description |
|---|---|---|
label | string | Row text (also used for typeahead). |
value | string | Optional id. |
icon | Component | Leading icon. |
shortcut | string | Display-only shortcut hint. |
disabled | boolean | Skip in keyboard navigation and ignore clicks. |
destructive | boolean | Paint the row in the destructive color. |
children | ContextMenuEntry[] | Entries of a submenu. |
onSelect | () => void | Called when the row is chosen. |
| Event | Payload | Description |
|---|---|---|
select | ContextMenuAction | A row (without children) was chosen. |
update:open | boolean | The menu opened or closed. |
| Exposed | Description |
|---|---|
openAt(x, y) | Open the menu at viewport coordinates. |
close() | Close the menu. |
Keyboard: focus the area and press Shift+F10 or the menu key to open. ↑/↓, Home/End and typeahead move the highlight, → opens a submenu, ← closes it, Enter selects and Esc closes and returns focus.