Living Documentation

Context menus for right-click actions, keyboard-accessible and fully token-driven

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.

Right-click trigger Keyboard accessible Nested sections Safe dismiss
Menu Items 9 across 3 sections
Last Action event log below
Trigger Count 0 right-click opens
Active Zone None last triggered on

1. Anatomy & Variants

Item types: action · destructive · disabled · separator · label · shortcut hint

Default context menu — right-click the zone below
Right-click anywhere here or press Shift+F10 when focused
File-manager zone — different action set
File manager area Open · Rename · Move to · Delete

2. Design Rules

Decisions that make context menus trustworthy

  • Always dismiss on Escape, outside click, or scroll.
  • Mirror the OS convention: destructive items last, labeled red.
  • Never navigate away on open — context menus are ephemeral.
  • Clamp to viewport: flip X if near right edge, flip Y if near bottom.
  • Disabled items must still be visible — reduce opacity, never hide.
  • Keyboard: Arrow Up/Down cycles, Enter activates, Esc closes.
  • ARIA: role="menu", items get role="menuitem".
  • Never use more than 7–9 items before grouping with separators.

3. Context Menu Playground

Configure menu items on the left, right-click any target card on the right, inspect the event log

9 items 3 sections
Document
Image
Event log
Right-click a target above to see events...

4. Positioning Algorithm

How the menu clamps to the viewport without overflow

Step-by-step
  1. Set left = event.clientX, top = event.clientY.
  2. Measure menu width/height after making it temporarily visible (visibility:hidden; display:flex).
  3. If left + menuW > window.innerWidth = left = left - menuW.
  4. If top + menuH > window.innerHeight = top = top - menuH.
  5. Clamp to 0 as a lower bound for both axes.
  6. Apply final left / top and set display:flex.
positioning.js
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';
}

5. Keyboard Navigation & ARIA

Full keyboard support with correct ARIA roles

Key bindings
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
aria markup
<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>

6. Production-ready Snippet

Drop-in context menu with positioning, keyboard nav, and safe dismiss

context-menu-full.js
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(); }
}
© Lumora — Hand-crafted with ❤️ care & passion.
v 1.0.0
Action triggered.