JavaScript Context Menu: Using preventDefault()

Sep 11, 2026·21 min read

To prevent the native context menu in JavaScript, handle the contextmenu event on the intended element and call event.preventDefault(), then provide an accessible replacement if users still need those actions.

A file list opened a useful action menu on right-click, but it also suppressed the browser menu across the rest of the page. Text fields, links and unrelated images lost commands they still needed.

Prevent the Native Context Menu

The browser has a default action for contextmenu: show its own context menu when that action is supported. preventDefault() requests cancellation of that action.

Start with a standalone element-scoped example:

<div id="canvas" tabindex="0">
  Right-click inside this area.
</div>

<script>
  const canvas = document.querySelector("#canvas");

  canvas.addEventListener("contextmenu", (event) => {
    event.preventDefault();
  });
</script>

The listener receives contextmenu only when the event reaches #canvas. Calling preventDefault() suppresses the native menu there, while right-clicking elsewhere keeps the browser behavior.

Where does preventDefault() apply?Page#canvaslistener boundary1native menu canceled2BrowsermenuElsewhere: unchanged
The listener changes browser behavior only inside its element’s boundary.

preventDefault() cancels the default action. It does not stop event propagation.

One event, two separate effectstarget: nested spanregion handlercalls preventDefault()document listenerstill bubblesblockedbrowser’snative menudefaultPrevented = true
Propagation and the browser’s default action are separate paths.

That distinction matters once several elements listen for the same event. The event can continue bubbling through ancestors after one handler cancels the menu, and later handlers can inspect event.defaultPrevented to see what happened.

Old code sometimes puts the cancellation directly in markup:

<div oncontextmenu="return false">
  Right-click inside this area.
</div>

Returning false from that inline event handler cancels the action. Returning false from an addEventListener() callback does not.

Use addEventListener() for application code. It keeps behavior out of the markup, supports multiple listeners, and gives you a listener you can remove during teardown.

Canceling the menu without supplying another way to perform the removed commands leaves a dead interaction. Suppression and replacement are different jobs.

How the contextmenu Event Reaches Your Handler

The contextmenu event fires when the user attempts to open a context menu, commonly through a secondary mouse button or a keyboard context-menu command. It bubbles, so one ancestor can handle attempts made on many descendants.

The event exposes several pieces of the dispatch:

  • target is the deepest element where the event began.
  • currentTarget is the element whose listener is currently running.
  • cancelable reports whether the event supports cancellation; cancellation can still be blocked by a passive listener.
  • defaultPrevented becomes true after cancellation succeeds.
  • For pointer-triggered events, clientX and clientY give the pointer position inside the viewport; coordinates from keyboard-triggered events are not portable.

Say a nested <span> inside a file card receives the secondary click. event.target can be that span while event.currentTarget is the file-list element holding the delegated listener.

The browser first invokes capture listeners on the path toward the target. At-target listeners run around the target, then non-capture listeners on ancestors run outward during bubbling. Introduction to browser events develops that path from the beginning.

The final example later in this article prints four trace entries for each handled attempt:

document capture: defaultPrevented=false
region capture: defaultPrevented=false
region handler: defaultPrevented=true
document bubble: defaultPrevented=true

The first listeners see the event before cancellation. The region handler calls preventDefault(), and the document bubble listener receives the same event with defaultPrevented set.

Three methods change three different parts of this process:

  • preventDefault() requests cancellation of the default browser action.
  • stopPropagation() stops the event from moving farther along its path.
  • stopImmediatePropagation() also prevents later listeners on the same element from running.

stopPropagation() is not a substitute for preventDefault(). An event can stop moving while its default action still runs, or keep bubbling after its default action has been canceled.

This is visible state, not timing folklore. Inspect cancelable and defaultPrevented while debugging in the browser and the event tells you whether cancellation was possible and whether a handler requested it.

Limit Prevention to the Right Elements

A document-wide handler is short:

document.addEventListener("contextmenu", (event) => {
  event.preventDefault();
});

It is also broad. Links, editable fields, images and every other descendant lose the native menu, including elements that have nothing to do with the feature.

There are three useful scopes:

Listener locationWhat it handlesSuitable use
One elementThat element and its descendantsOne isolated interactive surface
documentThe whole documentAn application that deliberately replaces the menu everywhere
A shared region with closest()Matching descendants inside that regionLists containing nested or dynamically added items

Scoped delegation is the practical rule for a growing list. Attach one listener to the stable container, find the nearest matching item with closest(), and ignore events that do not belong to one.

Here is the core extract used by the final implementation:

function getContextItem(target) {
  if (!(target instanceof Element)) {
    return null;
  }

  const item = target.closest("[data-context-item]");
  return item && region.contains(item) ? item : null;
}

region.addEventListener("contextmenu", (event) => {
  const item = getContextItem(event.target);

  if (!item) {
    return;
  }

  event.preventDefault();
  openMenu(item, {
    x: event.clientX,
    y: event.clientY,
    returnFocus: item,
    anchor: item,
  });
});

The containment check matters when selectors or nested components become more complicated. A matching ancestor outside region must not become an item for this handler.

Because the listener belongs to the stable region, a new matching item works without receiving another listener. Event delegation covers the same arrangement for other bubbling events, while Delegation in JavaScript: Events, Prototypes, Pitfalls separates event delegation from the language’s other delegation mechanisms.

Build a Custom Context Menu

A custom context menu needs more than cancellation. It must remember which item invoked it, open near the pointer, remain inside the viewport when it fits, run the selected action against the remembered item, and close when the user interacts elsewhere.

The next three blocks form the article’s single canonical implementation. Put the HTML in the document body, the CSS inside a <style> element and the JavaScript inside a <script> element.

Start with two context items, visible menu buttons, an insertion control and the hidden application menu:

<section id="context-demo" aria-labelledby="context-demo-title">
  <h2 id="context-demo-title">Project files</h2>

  <div id="context-region">
    <article tabindex="0" data-context-item data-title="Budget notes">
      <span>Budget <strong>notes</strong></span>
      <button
        type="button"
        data-menu-trigger
        aria-haspopup="menu"
        aria-controls="context-menu"
        aria-expanded="false"
      >
        Actions for Budget notes
      </button>
    </article>

    <article tabindex="0" data-context-item data-title="Launch checklist">
      <span>Launch <strong>checklist</strong></span>
      <button
        type="button"
        data-menu-trigger
        aria-haspopup="menu"
        aria-controls="context-menu"
        aria-expanded="false"
      >
        Actions for Launch checklist
      </button>
    </article>
  </div>

  <button id="add-context-item" type="button">Add a new file</button>
  <p id="context-status" role="status" aria-live="polite"></p>

  <h3>Event path trace</h3>
  <ol id="context-trace" aria-live="polite"></ol>
</section>

<div id="context-menu" role="menu" aria-label="File actions" hidden>
  <button type="button" role="menuitem" tabindex="-1" data-action="Open">
    Open
  </button>
  <button type="button" role="menuitem" tabindex="-1" data-action="Duplicate">
    Duplicate
  </button>
  <button type="button" role="menuitem" tabindex="-1" data-action="Archive">
    Archive
  </button>
</div>

The context items are focusable because they can invoke the menu from the keyboard. Each visible trigger provides the same actions without requiring a secondary click or knowledge of a keyboard shortcut.

Give the menu fixed positioning so its coordinates use the same viewport coordinate system as clientX and clientY:

#context-region {
  display: grid;
  gap: 0.75rem;
  max-width: 32rem;
}

[data-context-item] {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.75rem;
  border: 1px solid currentColor;
}

#context-menu {
  position: fixed;
  z-index: 10;
  min-width: 10rem;
  padding: 0.25rem;
  border: 1px solid currentColor;
  background: Canvas;
  color: CanvasText;
}

#context-menu [role="menuitem"] {
  display: block;
  width: 100%;
  padding: 0.5rem;
  text-align: left;
}

Now add the event handling, viewport clamping, event trace and teardown:

globalThis.contextMenuDemo?.destroy?.();

const demo = document.querySelector("#context-demo");
const region = document.querySelector("#context-region");
const menu = document.querySelector("#context-menu");
const status = document.querySelector("#context-status");
const trace = document.querySelector("#context-trace");
const addButton = document.querySelector("#add-context-item");
const controller = new AbortController();
const { signal } = controller;

let activeItem = null;
let returnFocusTo = null;
let expandedTrigger = null;
let addedCount = 0;

function getContextItem(target) {
  if (!(target instanceof Element)) {
    return null;
  }

  const item = target.closest("[data-context-item]");
  return item && region.contains(item) ? item : null;
}

function menuItems() {
  return [...menu.querySelectorAll('[role="menuitem"]')];
}

function writeTrace(label, event) {
  const entry = document.createElement("li");
  const targetName =
    event.target instanceof Element
      ? event.target.tagName.toLowerCase()
      : "unknown";

  entry.textContent =
    `${label}: target=${targetName}, ` +
    `currentTarget=${event.currentTarget === document ? "document" : "region"}, ` +
    `cancelable=${event.cancelable}, ` +
    `defaultPrevented=${event.defaultPrevented}`;

  trace.append(entry);
}

function positionMenu(x, y, anchor) {
  const gap = 8;
  menu.style.visibility = "hidden";
  menu.style.left = "0px";
  menu.style.top = "0px";

  const menuRect = menu.getBoundingClientRect();
  const anchorRect = anchor.getBoundingClientRect();
  const requestedLeft = Number.isFinite(x) ? x : anchorRect.left;
  const requestedTop = Number.isFinite(y) ? y : anchorRect.bottom;

  const maximumLeft = Math.max(gap, window.innerWidth - menuRect.width - gap);
  const maximumTop = Math.max(gap, window.innerHeight - menuRect.height - gap);

  const left = Math.min(Math.max(gap, requestedLeft), maximumLeft);
  const top = Math.min(Math.max(gap, requestedTop), maximumTop);

  menu.style.left = `${left}px`;
  menu.style.top = `${top}px`;
  menu.style.visibility = "";
}

function openMenu(item, { x, y, returnFocus, anchor }) {
  closeMenu({ restoreFocus: false });

  activeItem = item;
  returnFocusTo = returnFocus;
  expandedTrigger = item.querySelector("[data-menu-trigger]");
  expandedTrigger?.setAttribute("aria-expanded", "true");

  menu.hidden = false;
  positionMenu(x, y, anchor);
  menuItems()[0].focus();
}

function closeMenu({ restoreFocus }) {
  if (menu.hidden) {
    return;
  }

  menu.hidden = true;
  expandedTrigger?.setAttribute("aria-expanded", "false");

  const focusTarget = returnFocusTo;
  activeItem = null;
  returnFocusTo = null;
  expandedTrigger = null;

  if (restoreFocus && focusTarget?.isConnected) {
    focusTarget.focus();
  }
}

function runAction(action) {
  if (!activeItem) {
    return;
  }

  status.textContent = `${action}: ${activeItem.dataset.title}`;
}

document.addEventListener(
  "contextmenu",
  (event) => {
    if (region.contains(event.target)) {
      trace.replaceChildren();
      writeTrace("document capture", event);
    }
  },
  { capture: true, signal }
);

region.addEventListener(
  "contextmenu",
  (event) => {
    writeTrace("region capture", event);
  },
  { capture: true, signal }
);

region.addEventListener(
  "contextmenu",
  (event) => {
    const item = getContextItem(event.target);

    if (!item) {
      return;
    }

    event.preventDefault();
    writeTrace("region handler", event);

    openMenu(item, {
      x: event.clientX,
      y: event.clientY,
      returnFocus: item,
      anchor: item,
    });
  },
  { signal }
);

document.addEventListener(
  "contextmenu",
  (event) => {
    if (region.contains(event.target)) {
      writeTrace("document bubble", event);
    }
  },
  { signal }
);

region.addEventListener(
  "click",
  (event) => {
    const trigger =
      event.target instanceof Element
        ? event.target.closest("[data-menu-trigger]")
        : null;

    if (!trigger || !region.contains(trigger)) {
      return;
    }

    const item = getContextItem(trigger);

    openMenu(item, {
      x: null,
      y: null,
      returnFocus: trigger,
      anchor: trigger,
    });
  },
  { signal }
);

menu.addEventListener(
  "click",
  (event) => {
    const menuItem =
      event.target instanceof Element
        ? event.target.closest('[role="menuitem"]')
        : null;

    if (!menuItem || !menu.contains(menuItem)) {
      return;
    }

    runAction(menuItem.dataset.action);
    closeMenu({ restoreFocus: true });
  },
  { signal }
);

document.addEventListener(
  "pointerdown",
  (event) => {
    if (!menu.hidden && !menu.contains(event.target)) {
      const focusTarget =
        event.target instanceof Element
          ? event.target.closest(
              'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]'
            )
          : null;

      closeMenu({ restoreFocus: !focusTarget });
    }
  },
  { signal }
);

document.addEventListener(
  "focusin",
  (event) => {
    if (
      !menu.hidden &&
      !menu.contains(event.target) &&
      !region.contains(event.target)
    ) {
      closeMenu({ restoreFocus: false });
    }
  },
  { signal }
);

window.addEventListener(
  "resize",
  () => closeMenu({ restoreFocus: true }),
  { signal }
);

window.addEventListener(
  "scroll",
  () => closeMenu({ restoreFocus: true }),
  { signal, capture: true }
);

addButton.addEventListener(
  "click",
  () => {
    addedCount += 1;
    const title = `Draft report ${addedCount}`;
    const item = document.createElement("article");
    const label = document.createElement("span");
    const trigger = document.createElement("button");

    item.tabIndex = 0;
    item.dataset.contextItem = "";
    item.dataset.title = title;
    label.textContent = title;

    trigger.type = "button";
    trigger.dataset.menuTrigger = "";
    trigger.setAttribute("aria-haspopup", "menu");
    trigger.setAttribute("aria-controls", "context-menu");
    trigger.setAttribute("aria-expanded", "false");
    trigger.textContent = `Actions for ${title}`;

    item.append(label, trigger);
    region.append(item);
  },
  { signal }
);

region.addEventListener(
  "keydown",
  (event) => {
    const opensContextMenu =
      event.key === "ContextMenu" ||
      (event.shiftKey && event.key === "F10");

    if (!opensContextMenu) {
      return;
    }

    const item = getContextItem(event.target);

    if (!item) {
      return;
    }

    event.preventDefault();

    openMenu(item, {
      x: null,
      y: null,
      returnFocus: document.activeElement,
      anchor: item,
    });
  },
  { signal }
);

menu.addEventListener(
  "keydown",
  (event) => {
    const items = menuItems();
    const currentIndex = Math.max(0, items.indexOf(document.activeElement));
    let nextIndex = currentIndex;

    switch (event.key) {
      case "ArrowDown":
        nextIndex = (currentIndex + 1) % items.length;
        break;
      case "ArrowUp":
        nextIndex = (currentIndex - 1 + items.length) % items.length;
        break;
      case "Home":
        nextIndex = 0;
        break;
      case "End":
        nextIndex = items.length - 1;
        break;
      case "Enter":
      case " ":
        event.preventDefault();
        runAction(items[currentIndex].dataset.action);
        closeMenu({ restoreFocus: true });
        return;
      case "Escape":
        event.preventDefault();
        closeMenu({ restoreFocus: true });
        return;
      default:
        return;
    }

    event.preventDefault();
    items[nextIndex].focus();
  },
  { signal }
);

globalThis.contextMenuDemo = {
  destroy() {
    controller.abort();
    closeMenu({ restoreFocus: true });
  },
};

positionMenu() first measures the visible menu, then clamps the requested coordinates between an eight-pixel gap and the available right and bottom edges. When the menu fits within those gaps, a request near a viewport corner moves it inward instead of overflowing.

Clamping keeps the menu on-screenviewportrequested pointwould overflowclampedmenuOpenArchive8 px gapinside every edge
Clamping preserves the requested corner when possible and shifts the menu inward when necessary.

activeItem preserves the invoking file while focus moves into the menu. The delegated region listeners also recognize the files created by the final button, because those listeners remain on #context-region.

The trace makes cancellation visible. Secondary-click the nested <strong> text and target reports strong, currentTarget reports region inside the delegated handlers, and defaultPrevented changes after the region handler runs.

Make the Replacement Work Without a Mouse

An ARIA application menu is a compact set of commands operated as one keyboard widget. It is different from ordinary website navigation, where native links and buttons usually need no menu or menuitem roles.

Roles provide semantics, but they do not implement behavior. The script must move focus, interpret navigation keys, activate commands and return focus when the menu closes.

The canonical JavaScript already includes these keyboard listeners before the globalThis.contextMenuDemo assignment:

region.addEventListener(
  "keydown",
  (event) => {
    const opensContextMenu =
      event.key === "ContextMenu" ||
      (event.shiftKey && event.key === "F10");

    if (!opensContextMenu) {
      return;
    }

    const item = getContextItem(event.target);

    if (!item) {
      return;
    }

    event.preventDefault();

    openMenu(item, {
      x: null,
      y: null,
      returnFocus: document.activeElement,
      anchor: item,
    });
  },
  { signal }
);

menu.addEventListener(
  "keydown",
  (event) => {
    const items = menuItems();
    const currentIndex = items.indexOf(document.activeElement);
    let nextIndex = currentIndex;

    switch (event.key) {
      case "ArrowDown":
        nextIndex = (currentIndex + 1) % items.length;
        break;
      case "ArrowUp":
        nextIndex = (currentIndex - 1 + items.length) % items.length;
        break;
      case "Home":
        nextIndex = 0;
        break;
      case "End":
        nextIndex = items.length - 1;
        break;
      case "Enter":
      case " ":
        event.preventDefault();
        document.activeElement.click();
        return;
      case "Escape":
        event.preventDefault();
        closeMenu({ restoreFocus: true });
        return;
      default:
        return;
    }

    event.preventDefault();
    items[nextIndex].focus();
  },
  { signal }
);

Pointer invocation supplies clientX and clientY. Keyboard invocation instead passes null, so positionMenu() places the menu below the invoking item. This is an implementation fallback, not a claim that every browser supplies one universal coordinate value for keyboard-triggered contextmenu events.

When the menu opens, focus moves to its first command. ArrowDown and ArrowUp wrap through the items, Home and End jump to the edges, and Enter or Space activates the focused command. Escape closes the menu and restores focus to the invoking item or visible trigger.

Focus has a round tripinvoking filefocus starts hereopenFile actionsOpen ·focusedDuplicateArchiveArrow keys wrapEscape: restore focusEnter or Space runs the command
Keyboard focus travels into the menu, wraps through commands, and returns when the menu closes.

The WAI-ARIA menu pattern defines these focus and keyboard expectations. Test the context-menu key and Shift+F10 in the browsers and operating systems the application supports, since available keyboard commands depend on that environment.

A visible trigger remains necessary. It makes the commands discoverable, gives touch and keyboard users a direct control, and still works when a context-menu shortcut is unavailable.

These browser interactions sit beyond JavaScript syntax alone. JavaScript Fundamentals connects events, DOM state and reusable component structure when you need the longer path through those pieces.

Debug preventDefault When It Does Not Work

A failed cancellation usually leaves evidence in the event or the listener setup. Check the narrow facts first:

Where did cancellation break?menu attemptcontextmenureceived?noinspect listener,scope, teardownor browserexceptionyescancelable?nocannotcancelthis eventyespreventDefault()then verify true
Debugging starts by locating where the cancellation path breaks.
  1. Confirm the handler listens for contextmenu, not only click, mousedown or pointerdown.
  2. Confirm the listener exists before the interaction and is attached to the element or ancestor that receives the event.
  3. Log event.target and verify that closest() finds the intended item.
  4. Log event.cancelable. Calling preventDefault() cannot cancel a non-cancelable event.
  5. Log event.defaultPrevented immediately after preventDefault().
  6. Remove { passive: true } from any listener expected to cancel the event.
  7. Inspect framework event wrappers and confirm the native event receives preventDefault().
  8. Confirm teardown has not already removed the listener.

Synthetic tests need the same care. A programmatically dispatched event must be created as cancelable if the test expects cancellation:

const syntheticMenuAttempt = new MouseEvent("contextmenu", {
  bubbles: true,
  cancelable: true,
  clientX: 24,
  clientY: 32,
});

const item = region.querySelector("[data-context-item]");
const defaultAllowed = item.dispatchEvent(syntheticMenuAttempt);

console.log(syntheticMenuAttempt.cancelable);
console.log(syntheticMenuAttempt.defaultPrevented);
console.log(defaultAllowed);

Because the canonical handler receives this event on a matching context item, the three values are true, true and false. dispatchEvent() returns false when a cancelable event was canceled. Dispatching custom events covers synthetic dispatch in more detail.

Firefox has one deliberate exception: Shift plus secondary-click can show the browser menu without firing contextmenu. No handler can cancel an event it never receives. Keep the replacement available through its visible buttons and keyboard behavior instead of treating native-menu suppression as absolute.

Listener cleanup also belongs in the implementation. The example registers every listener with one AbortSignal, and contextMenuDemo.destroy() calls controller.abort() to remove them together. A stored function passed to removeEventListener() is another valid approach; an anonymous function recreated during teardown is not the same listener.

Finally, disabling right-click does not protect images, source code or other resources delivered to the browser. It removes one route to a menu. It does not remove the content, and blocking selection, developer shortcuts or unrelated keyboard commands only breaks more browser behavior.

Frequently asked questions

How do I prevent the context menu in JavaScript?
Listen for the contextmenu event and call event.preventDefault() inside the handler. Attach the listener to the smallest region that needs different behavior instead of blocking the menu across the whole document.
Does preventDefault stop contextmenu from bubbling?
No. preventDefault() cancels the browser's default action, but the event continues through its propagation path. Use propagation methods only when another listener must not receive the event.
Why does preventDefault not block the context menu?
Check that the listener receives contextmenu, event.cancelable is true, and the listener is not passive. Firefox can also show its native menu for Shift-right-click without firing contextmenu, which leaves no event for the handler to cancel.
Can disabling right-click protect images or source code?
No. Canceling contextmenu removes one browser interaction, but it does not remove downloaded resources or source code. Content delivered to the browser remains available through other browser and network features.
How do I make a custom context menu keyboard accessible?
Provide a visible button and support the keyboard context-menu key or Shift+F10 where available. Move focus into the opened menu, implement Arrow keys, Home, End, Enter, Space and Escape, then restore focus when the menu closes.