Theme
Design your own
On this page (6)
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.

§API

Generated from context-menu.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type ContextMenuState = 'default' | 'open' - setState(name, config) takes the config of the state it names.

StateDescription
default
Closed.

No config.

open
Open at a point of the viewport.
Config fieldTypeDescription
x?numberthe menu's left edge, px from the viewport's left (default 8)
y?numberthe menu's top edge, px from the viewport's top (default 8)

Every element

MemberDescription
el.api.setState<S extends ContextMenuState>(name: S, config?: ContextMenuStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?ContextMenuStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: ContextMenuState; config: ContextMenuStateConfigs[ContextMenuState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: ContextMenuState; config: ContextMenuStateConfigs[ContextMenuState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: ContextMenuState; config: ContextMenuStateConfigs[ContextMenuState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: ContextMenuState; config: ContextMenuStateConfigs[ContextMenuState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: ContextMenuState; config: ContextMenuStateConfigs[ContextMenuState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.contextMenuApi.setState<S extends ContextMenuState>(el: HTMLElement, name: S, config?: ContextMenuStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?ContextMenuStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.contextMenuApi.getState(el: HTMLElement): { name: ContextMenuState; config: ContextMenuStateConfigs[ContextMenuState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: ContextMenuState; config: ContextMenuStateConfigs[ContextMenuState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.contextMenuApi.render(state: { name: ContextMenuState; config: ContextMenuStateConfigs[ContextMenuState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: ContextMenuState; config: ContextMenuStateConfigs[ContextMenuState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.contextMenuApi.store(el: HTMLElement): Store<{ name: ContextMenuState; config: ContextMenuStateConfigs[ContextMenuState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: ContextMenuState; config: ContextMenuStateConfigs[ContextMenuState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.contextMenuApi.commit<S extends ContextMenuState>(el: HTMLElement, name: S, config?: ContextMenuStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?ContextMenuStateConfigs[S]its config
df$.shadcn.contextMenuStates: ContextMenuState[]The declared states, 'default' first: default, open.

§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, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const contextMenuStates = ['default', 'open'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state. */
export interface ContextMenuStateConfigs {
  /** Closed. */
  default: {};
  /** Open at a point of the viewport. */
  open: {
    /** the menu's left edge, px from the viewport's left (default 8) */
    x?: number;
    /** the menu's top edge, px from the viewport's top (default 8) */
    y?: number;
  };
}
/** 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 });
}
/**
 * The markup of a state: none - 'open' lives in the top layer
 * (:popover-open), not in an attribute, so every state renders the authored
 * markup. render() stays the State API's markup function all the same.
 */
function applyMarkup(_el, _stateName) {}
/**
 * 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 = componentState({
  component: 'context-menu',
  states: contextMenuStates,
  apply: (menu, state) => triggerStateChange(menu, state.name, state.config),
  read: (menu, state) => {
    // reflect reality: right-clicks and item clicks change the UI too
    return {
      name: menu.matches(':popover-open') ? 'open' : 'default',
      config: state.config,
    };
  },
  markup: (el, state) => applyMarkup(el, state.name),
});
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() {
  dfDollar('[data-context-menu]:not([data-init])').toArray().forEach((trigger) => {
  trigger.dataset.init = '';
  const menu = dfDollar('#' + CSS.escape(trigger.dataset.contextMenu)).get(0);
  if (!menu) return;
  // bind-scope the api per menu popover: `$('#my-ctx').api.setState('open', { x: 40, y: 40 })`
  // el.store + el.api (AGENTS.md "State through stores")
  bindComponent(menu, contextMenuApi);
  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 as HTMLElement).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