🔥 Oxide UI v0.1.0 — Dark mode only, copy-paste ready. Get started →
Skip to content

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>
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 needs role="menuitem"
  • On open, focus the first item; on close, return focus to the element that opened the menu
  • Keyboard: ↑↓ navigate items, Enter/Space activate, Escape close
  • 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

Released under the MIT License.