Context Menu
<div role="menu"> + contextmenu event
A floating menu triggered by right-clicking a target area. Positioned at the cursor, clamped to the viewport, and dismissed on click-outside or Escape.
Overview
Preview
Right-click anywhere in the area below
Right-click here
Right-click target
html
<div
@contextmenu.prevent="onContextMenu"
class="flex h-32 w-full items-center justify-center rounded-xl border border-dashed
border-zinc-700 bg-zinc-900/40 text-sm text-zinc-600 select-none cursor-default"
>
Right-click here
</div>Menu panel
html
<div
ref="menuEl"
:style="{ top: y + 'px', left: x + 'px' }"
class="fixed z-50 w-52 overflow-hidden rounded-xl border border-zinc-700/60
bg-zinc-900 py-1.5 shadow-2xl shadow-black/50"
role="menu"
>
<button
v-for="item in menuItems"
:key="item.label"
@click="item.action(); close()"
class="flex w-full items-center gap-3 px-3.5 py-2 text-sm transition-colors"
:class="[
item.danger ? 'text-red-400 hover:bg-red-950/40' : 'text-zinc-300 hover:bg-zinc-800',
item.divider ? 'mt-1 border-t border-zinc-800 pt-1' : ''
]"
role="menuitem"
>
<span class="flex h-5 w-5 shrink-0 items-center justify-center text-base">{{ item.icon }}</span>
<span class="flex-1 text-left">{{ item.label }}</span>
<kbd v-if="item.shortcut" class="text-[10px] text-zinc-700 font-mono">{{ item.shortcut }}</kbd>
</button>
</div>Full Vue implementation
vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const visible = ref(false)
const x = ref(0)
const y = ref(0)
const menuEl = ref(null)
const menuItems = [
{ icon: '✂️', label: 'Cut', shortcut: '⌘X', action: () => {} },
{ icon: '📋', label: 'Copy', shortcut: '⌘C', action: () => {} },
{ icon: '📌', label: 'Paste', shortcut: '⌘V', action: () => {}, divider: true },
{ icon: '✏️', label: 'Rename', shortcut: '', action: () => {} },
{ icon: '🗑', label: 'Delete', shortcut: '⌫', action: () => {}, danger: true, divider: true },
]
function onContextMenu(e) {
visible.value = false
// Clamp menu to viewport edges
const menuW = 208, menuH = menuItems.length * 40
x.value = Math.min(e.clientX, window.innerWidth - menuW - 8)
y.value = Math.min(e.clientY, window.innerHeight - menuH - 8)
visible.value = true
}
function close() { visible.value = false }
// Click outside
function onClickOutside(e) {
if (menuEl.value && !menuEl.value.contains(e.target)) close()
}
onMounted(() => {
document.addEventListener('click', onClickOutside)
document.addEventListener('keydown', e => e.key === 'Escape' && close())
document.addEventListener('scroll', close, { passive: true, capture: true })
})
onUnmounted(() => {
document.removeEventListener('click', onClickOutside)
document.removeEventListener('keydown', close)
document.removeEventListener('scroll', close, true)
})
</script>
<template>
<div @contextmenu.prevent="onContextMenu" class="...">
Right-click here
</div>
<Teleport to="body">
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="visible" ref="menuEl" :style="{ top: y + 'px', left: x + 'px' }" class="fixed z-50 ..." role="menu">
<!-- menu items -->
</div>
</Transition>
</Teleport>
</template>Section dividers
html
<!-- Divider + section label -->
<div class="my-1 border-t border-zinc-800"></div>
<p class="px-3.5 pb-1 pt-0.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-600">
Danger zone
</p>Accessibility notes
- The menu container needs
role="menu"; each item needsrole="menuitem" - On open, focus the first item; on close, return focus to the element that opened the menu
- Keyboard:
↑↓navigate items,Enter/Spaceactivate,Escapeclose - Danger actions should be visually distinct (red text) and positioned last, after a divider
- Don't use a context menu as the only way to access an action — always provide a primary UI path