JavaScript Context Menu: Using preventDefault()
To prevent the native context menu in JavaScript, handle the
contextmenuevent on the intended element and callevent.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.
preventDefault() cancels the default action. It does not stop event propagation.
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:
targetis the deepest element where the event began.currentTargetis the element whose listener is currently running.cancelablereports whether the event supports cancellation; cancellation can still be blocked by a passive listener.defaultPreventedbecomestrueafter cancellation succeeds.- For pointer-triggered events,
clientXandclientYgive 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 location | What it handles | Suitable use |
|---|---|---|
| One element | That element and its descendants | One isolated interactive surface |
document | The whole document | An application that deliberately replaces the menu everywhere |
A shared region with closest() | Matching descendants inside that region | Lists 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.
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.
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:
- Confirm the handler listens for
contextmenu, not onlyclick,mousedownorpointerdown. - Confirm the listener exists before the interaction and is attached to the element or ancestor that receives the event.
- Log
event.targetand verify thatclosest()finds the intended item. - Log
event.cancelable. CallingpreventDefault()cannot cancel a non-cancelable event. - Log
event.defaultPreventedimmediately afterpreventDefault(). - Remove
{ passive: true }from any listener expected to cancel the event. - Inspect framework event wrappers and confirm the native event receives
preventDefault(). - 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.