1. Anatomy & Variants
Item types: action · destructive · disabled · separator · label · shortcut hint
A custom context menu replaces the browser default, giving users familiar right-click UX with branded actions. This page documents anatomy, positioning, keyboard nav, and safe dismissal patterns.
Item types: action · destructive · disabled · separator · label · shortcut hint
Decisions that make context menus trustworthy
role="menu", items get
role="menuitem".
Configure menu items on the left, right-click any target card on the right, inspect the event log
How the menu clamps to the viewport without overflow
left = event.clientX,
top = event.clientY.
visibility:hidden; display:flex).
left + menuW > window.innerWidth =
left = left - menuW.
top + menuH > window.innerHeight =
top = top - menuH.
left / top and set
display:flex.
function positionMenu(menu, x, y) {
menu.style.visibility = 'hidden';
menu.style.display = 'flex';
const mw = menu.offsetWidth;
const mh = menu.offsetHeight;
menu.style.display = '';
menu.style.visibility = '';
const left = Math.max(0, x + mw > innerWidth ? x - mw : x);
const top = Math.max(0, y + mh > innerHeight ? y - mh : y);
menu.style.left = left + 'px';
menu.style.top = top + 'px';
}
Full keyboard support with correct ARIA roles
| Key | Action |
|---|---|
| Arrow Down | Focus next enabled item |
| Arrow Up | Focus previous enabled item |
| Enter / Space | Activate focused item |
| Escape | Close menu, return focus |
| Tab | Close menu (focus leaves) |
| Home | Focus first enabled item |
| End | Focus last enabled item |
<div role="menu" aria-label="Actions">
<button role="menuitem" tabindex="-1">Copy</button>
<button role="menuitem" tabindex="-1"
aria-disabled="true">Paste</button>
<div role="separator"></div>
<button role="menuitem" tabindex="-1"
class="ctx-menu__item--danger">Delete</button>
</div>
Drop-in context menu with positioning, keyboard nav, and safe dismiss
class ContextMenu {
constructor(targetSelector, items) {
this.items = items;
this.el = this._build();
this._open = false;
this._returnFocus = null;
document.body.appendChild(this.el);
document.querySelectorAll(targetSelector).forEach(t => {
t.addEventListener('contextmenu', e => this._show(e));
t.addEventListener('keydown', e => {
if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {
e.preventDefault();
const r = t.getBoundingClientRect();
this._show(e, r.left + 8, r.bottom + 4);
}
});
});
document.addEventListener('click', () => this._hide());
document.addEventListener('keydown', e => { if (e.key === 'Escape' || e.key === 'Tab') this._hide(); });
document.addEventListener('scroll', () => this._hide(), { capture: true });
window.addEventListener('resize', () => this._hide());
}
_build() {
const el = document.createElement('div');
el.className = 'ctx-menu';
el.setAttribute('role', 'menu');
this.items.forEach(item => {
if (item.separator) { const s = document.createElement('div'); s.className = 'ctx-menu__sep'; s.setAttribute('role','separator'); el.appendChild(s); return; }
if (item.label) { const l = document.createElement('div'); l.className = 'ctx-menu__label'; l.textContent = item.label; el.appendChild(l); return; }
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ctx-menu__item' + (item.danger ? ' ctx-menu__item--danger' : '') + (item.disabled ? ' is-disabled' : '');
btn.setAttribute('role', 'menuitem');
btn.setAttribute('tabindex', '-1');
if (item.disabled) btn.setAttribute('aria-disabled', 'true');
btn.innerHTML = `<i class="${item.icon}"></i><span>${item.text}</span>` +
(item.kbd ? `<span class="ctx-menu__kbd">${item.kbd}</span>` : '');
btn.addEventListener('click', () => { if (!item.disabled) { item.action?.(); this._hide(); } });
el.appendChild(btn);
});
return el;
}
_show(e, forcedX, forcedY) {
e.preventDefault();
this._returnFocus = document.activeElement;
const x = forcedX ?? e.clientX;
const y = forcedY ?? e.clientY;
this.el.style.visibility = 'hidden';
this.el.classList.add('is-open');
const mw = this.el.offsetWidth, mh = this.el.offsetHeight;
this.el.classList.remove('is-open');
this.el.style.visibility = '';
const left = Math.max(0, x + mw > innerWidth ? x - mw : x);
const top = Math.max(0, y + mh > innerHeight ? y - mh : y);
this.el.style.left = left + 'px';
this.el.style.top = top + 'px';
this.el.classList.add('is-open');
this._focusFirst();
}
_hide() {
if (!this._open && !this.el.classList.contains('is-open')) return;
this.el.classList.remove('is-open');
this._returnFocus?.focus?.();
}
_items() { return [...this.el.querySelectorAll('[role="menuitem"]:not(.is-disabled)')]; }
_focusFirst() { this._items()[0]?.focus(); }
}