Theme
On this page (5)
Component Skill — components/context-menu/component-skill.md

Native basis

Popover API triggered by right-click.

Web Platform APIs

popover attributecontextmenu event

Classes

.context-menu-trigger.context-menu.context-menu-item.context-menu-separator

Data attributes

• data-context-menu

State attributes (managed by JS)

• data-highlighted

§Default

Right-click inside the box. Near the right or bottom edge of the window the menu opens toward the other side of the pointer - as a native menu does - so it never runs off screen.

§Density

Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - closed (the authored state; right-click opens it)
  • open - menu shown; setState('open', { x, y }) positions it (viewport top-left by default, there is no pointer to anchor to)

The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/context-menu-{state}.png.

Machine contract - verified against context-menu.schema.json by bun run verify:

StateTypeValuesDefaultDescription
openbooleantrue, falsefalseShown - driven through the component's own State API. The runtime positions it at x/y (default 8/8) first.

§CSS view file

Styles for the context-menu component. Uses design tokens for colors, spacing, and radius.

@layer components {
  .context-menu-trigger {
    display: flex; align-items: center; justify-content: center;
    border: 2px dashed var(--border); border-radius: var(--radius-lg);
    padding: 3rem; font-size: 0.875rem; color: var(--muted-foreground); cursor: default;
  }
  .context-menu {
    margin: 0; border: 1px solid var(--border); border-radius: var(--radius-lg);
    background-color: var(--popover); color: var(--popover-foreground);
    padding: 0.25rem; box-shadow: var(--shadow-md); min-width: 10rem;
    opacity: 0; transform: scale(0.95);
    transition: opacity 100ms ease, transform 100ms ease, display 100ms allow-discrete;
    &:popover-open { opacity: 1; transform: scale(1); }
  }
  @starting-style {
    .context-menu:popover-open { opacity: 0; transform: scale(0.95); }
  }
  .context-menu-item {
    display: flex; align-items: center; gap: 0.5rem; width: 100%;
    padding: 0.375rem 0.5rem; border: none; border-radius: var(--radius-md);
    background: transparent; color: var(--popover-foreground);
    font-size: 0.8125rem; font-family: inherit; text-align: left; cursor: pointer;
    transition: background-color 100ms ease;
    &:hover, &[data-highlighted] { background-color: var(--accent); color: var(--accent-foreground); }
    &:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
  }
  .context-menu-separator { height: 1px; background-color: var(--border); margin: 0.25rem -0.25rem; }
  /* -- Density ----------------------------------------------------
     data-density on the .context-menu root scales the container padding and
     the item rows. comfortable == the unsized default. */
  .context-menu:where([data-density="compact"]) {
    padding: 0.125rem;
    & .context-menu-item { padding: 0.25rem 0.5rem; }
  }
  .context-menu:where([data-density="comfortable"]) {
    padding: 0.25rem;
    & .context-menu-item { padding: 0.375rem 0.5rem; }
  }
  .context-menu:where([data-density="spacious"]) {
    padding: 0.375rem;
    & .context-menu-item { padding: 0.5rem 0.625rem; }
  }
}
/* Accessibility: suppress motion for users who request it (REQUIRED for all
   components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of
   `none` keeps transitionend/animationend (and discrete display flips)
   firing so JS state machines that await them keep working. */
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .context-menu-trigger,
    .context-menu-trigger *,
    .context-menu-trigger::before,
    .context-menu-trigger::after,
    .context-menu-trigger *::before,
    .context-menu-trigger *::after,
    .context-menu-trigger::backdrop,
    .context-menu,
    .context-menu *,
    .context-menu::before,
    .context-menu::after,
    .context-menu *::before,
    .context-menu *::after,
    .context-menu::backdrop,
    .context-menu-item,
    .context-menu-item *,
    .context-menu-item::before,
    .context-menu-item::after,
    .context-menu-item *::before,
    .context-menu-item *::after,
    .context-menu-item::backdrop,
    .context-menu-separator,
    .context-menu-separator *,
    .context-menu-separator::before,
    .context-menu-separator::after,
    .context-menu-separator *::before,
    .context-menu-separator *::after,
    .context-menu-separator::backdrop {
      transition-duration: 0.01ms !important;
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
    }
  }
}

§JavaScript view file

Interaction logic for the context-menu component. Uses data attributes for wiring.

// -- Context Menu ---------------------------------------------
// Right-click context menu using the Popover API, plus the named-state
// API bound per menu popover, so agents/tests can open it without a real
// right-click (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, safeShowPopover } from '../../shared/state-api.js';
const df$ = defussGlobals();
const contextMenuStates = ['default', 'open'];
/** Like a native menu: where there is no room right of / below the point,
 * open toward its other side, and never past the viewport. Measured right
 * after the (usually synchronous) show - else once the deferred show lands. */
function keepInView(menu, x, y) {
  const fit = () => {
    const w = menu.offsetWidth, h = menu.offsetHeight;
    const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
    if (x + w > vw - 4) menu.style.left = `${Math.max(4, Math.min(x - w, vw - w - 4))}px`;
    if (y + h > vh - 4) menu.style.top = `${Math.max(4, Math.min(y - h, vh - h - 4))}px`;
  };
  if (menu.matches(':popover-open')) fit();
  else menu.addEventListener('toggle', (e) => { if (e.newState === 'open') fit(); }, { once: true });
}
/**
 * UI side of setState (per menu popover): 'open' shows the menu at { x, y }
 * (falling back to the top-left of the viewport - there is no pointer event
 * to anchor to); 'default' hides it.
 */
function triggerStateChange(menu, stateName, config) {
  switch (stateName) {
    case 'default':
      menu.hidePopover();
      break;
    case 'open': {
      const x = Number(config?.x ?? 8);
      const y = Number(config?.y ?? 8);
      menu.style.position = 'fixed';
      menu.style.top = `${y}px`;
      menu.style.left = `${x}px`;
      // deferred show: showPopover() while a previous exit transition is
      // still running crashes the headless renderer (setState after Escape)
      safeShowPopover(menu);
      keepInView(menu, x, y);
      break;
    }
  }
}
/** Registry-level API; pass the menu popover explicitly. Unknown names throw. */
export const contextMenuApi = {
  setState(menu, stateName, config = {}) {
    if (!contextMenuStates.includes(stateName)) {
      throw new Error(`context-menu: unknown state "${stateName}" (supported: ${contextMenuStates.join(', ')})`);
    }
    triggerStateChange(menu, stateName, config);
    // state lives on the ELEMENT, not the module (many menus per page)
    menu.dataset.stateName = stateName;
    menu._stateConfig = config;
  },
  getState(menu) {
    // reflect reality: right-clicks and item clicks change the UI too
    return {
      name: menu.matches(':popover-open') ? 'open' : 'default',
      config: menu._stateConfig ?? {},
    };
  },
};
df$.contextMenuApi = contextMenuApi;
df$.contextMenuStates = contextMenuStates;
/* One pending open across all triggers: { menu, x, y } captured on the
   contextmenu event, consumed on the right-button pointerup. */
let pendingOpen = null;
/* Timestamp of the last right-button release (0 = never, i.e. page start) —
   see the contextmenu handler: the gesture normally fires contextmenu at
   button-DOWN (open must wait for the release), but some engines dispatch it
   AFTER the pointerup - then the gesture is already over and opening is safe. */
let lastRightUp = 0;
/* Document-level open-on-release - registered once (AGENTS.md delegation
   pattern). WHY release and not the contextmenu event itself: macOS fires
   contextmenu at mouse-DOWN, and an auto popover shown while the right button
   is still held is light-dismissed by the platform the moment it goes up —
   the menu flashed open and vanished on mouse-up (verified in Chromium). */
if (!document.__ctxMenuReleaseInit) {
  document.__ctxMenuReleaseInit = true;
  document.addEventListener('pointerup', (e) => {
    if (e.button !== 2) return;
    lastRightUp = performance.now();
    if (!pendingOpen) return;
    const { menu, x, y } = pendingOpen;
    pendingOpen = null;
    openMenuAt(menu, x, y);
  });
  document.addEventListener('pointercancel', () => { pendingOpen = null; });
}
/** Show a context menu fixed at the pointer coords. */
function openMenuAt(menu, x, y) {
  menu.style.position = 'fixed';
  menu.style.top = `${y}px`;
  menu.style.left = `${x}px`;
  safeShowPopover(menu);
  keepInView(menu, x, y);
  menu.dataset.stateName = 'open';
}
function init() {
  document.querySelectorAll('[data-context-menu]:not([data-init])').forEach((trigger) => {
  trigger.dataset.init = '';
  const menu = document.getElementById(trigger.dataset.contextMenu);
  if (!menu) return;
  // bind-scope the api per menu popover: `$('#my-ctx').api.setState('open', { x: 40, y: 40 })`
  menu.api = {
    setState: (stateName, config) => contextMenuApi.setState(menu, stateName, config),
    getState: () => contextMenuApi.getState(menu),
  };
  trigger.addEventListener('contextmenu', (e) => {
    e.preventDefault();
    // no pointer press (keyboard-synthesized, e.g. a11y tooling) → open next frame
    if (e.pointerId === undefined || e.pointerId < 0) {
      requestAnimationFrame(() => openMenuAt(menu, e.clientX, e.clientY));
      return;
    }
    // right-button already released (gesture order: pointerup → contextmenu)
    // → opening now can't be light-dismissed. lastRightUp===0 (page never saw a
    // right release) must NOT qualify - otherwise early page loads take this
    // branch for a still-held button (0 - now is meaningless).
    if (lastRightUp > 0 && performance.now() - lastRightUp < 100) {
      openMenuAt(menu, e.clientX, e.clientY);
      return;
    }
    pendingOpen = { menu, x: e.clientX, y: e.clientY };
  });
  menu.addEventListener('click', (e) => {
    if (e.target.closest('.context-menu-item')) {
      menu.hidePopover();
      menu.dataset.stateName = 'default';
    }
  });
  // right out of the top layer via Escape: keep the named state honest
  menu.addEventListener('toggle', (e) => {
    if (e.newState === 'closed') menu.dataset.stateName = 'default';
  });
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub