Build a custom HTML context menu (right-click menu)

One event, contextmenu, tells you the user asked for a menu. Cancel the browser's own menu, show yours at the cursor, and then handle the edges: the screen border, the keyboard and touch.

To make a custom context menu in HTML, listen for the contextmenu event, call preventDefault() to stop the browser's own menu, and show your menu element at the event's clientX and clientY.

HTML has no element that does this by itself, so a few lines of JavaScript are needed.

Right-click inside the box below. On a trackpad, use a two-finger click.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Custom context menu</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  .area {
    height: 200px; border: 2px dashed #b8c0cc; border-radius: 12px;
    display: grid; place-items: center; color: #4b5563; background: #fff;
  }
  .menu {
    position: fixed; margin: 0; padding: 4px; min-width: 150px;
    background: #fff; border: 1px solid #d9dde3; border-radius: 8px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, .16);
  }
  .menu button {
    display: block; width: 100%; padding: 8px 12px; border: 0; border-radius: 5px;
    background: none; font: inherit; text-align: left; cursor: pointer;
  }
  .menu button:hover, .menu button:focus { background: #e8f0fe; outline: none; }
  #out { margin: 10px 2px 0; color: #374151; }
</style>
</head>
<body>
<div class="area" id="area">Right-click anywhere in this box</div>
<p id="out">Nothing picked yet.</p>

<div class="menu" id="menu" role="menu" hidden>
  <button role="menuitem">Copy</button>
  <button role="menuitem">Rename</button>
  <button role="menuitem">Delete</button>
</div>

<script>
  const area = document.getElementById('area');
  const menu = document.getElementById('menu');
  const out = document.getElementById('out');

  area.addEventListener('contextmenu', (e) => {
    e.preventDefault();  // stop the browser's own menu
    menu.hidden = false;
    // flip left or up if the menu would leave the viewport
    let x = e.clientX, y = e.clientY;
    if (x + menu.offsetWidth > innerWidth) x -= menu.offsetWidth;
    if (y + menu.offsetHeight > innerHeight) y -= menu.offsetHeight;
    menu.style.left = Math.max(0, x) + 'px';
    menu.style.top = Math.max(0, y) + 'px';
  });

  const close = () => { menu.hidden = true; };

  // close on a press outside the menu, and on Escape
  document.addEventListener('pointerdown', (e) => {
    if (!menu.contains(e.target)) close();
  });
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') close();
  });

  menu.addEventListener('click', (e) => {
    const item = e.target.closest('button');
    if (!item) return;
    out.textContent = 'You picked: ' + item.textContent;
    close();
  });
</script>
</body>
</html>
A custom menu at the cursor. Escape or a click outside closes it.

The rest of this page covers what that first version leaves out: the screen edges, the keyboard, and touch screens.

How the contextmenu event works

The browser fires contextmenu when the user asks for a context menu. A right-click is the common way, but not the only one. The event bubbles, and it can be cancelled.

area.addEventListener('contextmenu', (e) => {
  e.preventDefault();          // no browser menu
  menu.hidden = false;
  menu.style.left = e.clientX + 'px';
  menu.style.top = e.clientY + 'px';
});

Three details make this work:

  1. preventDefault() stops the built-in menu. Without it, both menus appear.
  2. position: fixed on the menu, because clientX and clientY are measured from the viewport, not the page. With position: absolute, the menu drifts once the page scrolls. CSS position explains the difference.
  3. The listener sits on one element, not on document. Right-click keeps working on the rest of the page, where people use it to open links in a new tab or check spelling.

Keep the menu inside the viewport

A menu placed at the cursor runs off the screen when the user right-clicks near the right or bottom edge.

Near the corner, open the menu up and to the left of the cursor.
Near the corner, open the menu up and to the left of the cursor.

Measure the menu after it becomes visible, then flip it:

let x = e.clientX, y = e.clientY;
if (x + menu.offsetWidth > innerWidth) x -= menu.offsetWidth;
if (y + menu.offsetHeight > innerHeight) y -= menu.offsetHeight;
menu.style.left = Math.max(0, x) + 'px';
menu.style.top = Math.max(0, y) + 'px';

The order matters. offsetWidth is 0 while the menu is hidden, so set hidden = false first and measure second. Math.max(0, ...) covers a menu taller than the space above the cursor.

Close it: Escape, a click outside, a pick

An open menu should close when the user presses Escape, presses anywhere outside it, or picks an item. The first demo does all three with two document listeners:

document.addEventListener('pointerdown', (e) => {
  if (!menu.contains(e.target)) close();
});
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') close();
});

pointerdown closes the menu as soon as the button goes down, before the click lands on something else. Closing on window resize and scroll is also worth adding, because the menu no longer matches what is under it.

The popover attribute gives you the first two for free. A popover closes itself on Escape and on a press outside, and it is drawn above the rest of the page.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Context menu with popover</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  .area {
    height: 200px; border: 2px dashed #b8c0cc; border-radius: 12px;
    display: grid; place-items: center; color: #4b5563; background: #fff;
  }
  .menu {
    position: fixed; inset: auto;  /* undo the centred popover default */
    margin: 0; padding: 4px; min-width: 150px;
    background: #fff; border: 1px solid #d9dde3; border-radius: 8px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, .16);
  }
  .menu button {
    display: block; width: 100%; padding: 8px 12px; border: 0; border-radius: 5px;
    background: none; font: inherit; text-align: left; cursor: pointer;
  }
  .menu button:hover, .menu button:focus { background: #e8f0fe; outline: none; }
  #out { margin: 10px 2px 0; color: #374151; }
</style>
</head>
<body>
<div class="area" id="area">Right-click here (popover version)</div>
<p id="out">Nothing picked yet.</p>

<!-- popover (auto) closes itself on Escape and on a click outside -->
<div class="menu" id="menu" popover role="menu">
  <button role="menuitem">Copy</button>
  <button role="menuitem">Rename</button>
  <button role="menuitem">Delete</button>
</div>

<script>
  const area = document.getElementById('area');
  const menu = document.getElementById('menu');
  const out = document.getElementById('out');

  area.addEventListener('contextmenu', (e) => {
    e.preventDefault();
    if (!menu.matches(':popover-open')) menu.showPopover();
    let x = e.clientX, y = e.clientY;
    if (x + menu.offsetWidth > innerWidth) x -= menu.offsetWidth;
    if (y + menu.offsetHeight > innerHeight) y -= menu.offsetHeight;
    menu.style.left = Math.max(0, x) + 'px';
    menu.style.top = Math.max(0, y) + 'px';
  });

  menu.addEventListener('click', (e) => {
    const item = e.target.closest('button');
    if (!item) return;
    out.textContent = 'You picked: ' + item.textContent;
    menu.hidePopover();
  });
</script>
</body>
</html>
The same menu as a popover. No close code for Escape or a click outside.

Two CSS lines are needed, because a popover is centred by default: inset: auto and margin: 0. After that, left and top work as before.

hidden + your own listeners popover attribute
Escape closes it You write it Built in
Click outside closes it You write it Built in
Above other content Needs a high z-index Drawn in the top layer
Position left / top left / top, after resetting inset
Open it menu.hidden = false menu.showPopover()

Open it from the keyboard: Shift+F10 and the Menu key

Keyboard users have two ways to ask for a context menu: the Menu key, on keyboards that have one, and Shift+F10 on Windows. Both fire contextmenu on the element that has focus.

Two things follow. The element must be able to take focus, so a div or li needs tabindex="0" (tabindex covers the values). And the event's clientX and clientY are not a mouse position, so place the menu next to the focused element instead.

let viaKey = false;
document.addEventListener('keydown', (e) => {
  viaKey = e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10');
}, true);

In the contextmenu listener, if viaKey is true, read getBoundingClientRect() and open the menu under the row.

Focus moves into the menu, arrow keys move inside it, and focus returns to the row.
Focus moves into the menu, arrow keys move inside it, and focus returns to the row.

Once the menu is open, the keyboard has to reach it:

  • Move focus in. Focus the first item when the menu opens.
  • Arrow keys. ArrowDown and ArrowUp move between items and wrap at the ends. Home and End jump to the first and last.
  • Buttons as items. Enter and Space then pick an item with no extra code.
  • Give focus back. On Escape or a pick, focus the row the menu came from, so the user is not dropped at the top of the page.

The items use role="menuitem" inside a role="menu", the WAI-ARIA menu pattern that screen readers announce. Those roles promise arrow-key movement, so add both or neither.

Long press on touch screens

A phone has no right button. Whether a long press fires contextmenu depends on the browser, so a timer makes the menu open on all of them:

row.addEventListener('pointerdown', (e) => {
  if (e.pointerType !== 'touch') return;
  sx = e.clientX; sy = e.clientY;
  timer = setTimeout(() => openFor(row, sx, sy), 500);
});

Cancel the timer on pointerup and pointercancel, and on pointermove once the finger has travelled more than 10 pixels. A finger that moves is scrolling, not pressing.

The demo waits 500 ms. If the browser also fires contextmenu, openFor just runs a second time and the menu stays open.

Add user-select: none to the pressed element so the long press does not start selecting text, and -webkit-touch-callout: none for the press-and-hold popup that Safari on iPhone shows for links and images. Making text not highlightable goes into both.

For more about touch input, see touch events in JavaScript.

A finished example: a file list

This version puts it all together. Each file row opens the same menu by right-click, by long press, or by Shift+F10 and the Menu key.

Three ways to ask for a menu, one function to open it.
Three ways to ask for a menu, one function to open it.
Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Context menu: mouse, keyboard and touch</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  ul.files { list-style: none; margin: 0; padding: 0; background: #fff; border-radius: 12px; border: 1px solid #e1e4ea; }
  .row {
    padding: 13px 16px; border-bottom: 1px solid #eef0f3; cursor: default;
    user-select: none; -webkit-user-select: none;
    -webkit-touch-callout: none;  /* no iOS callout on long press */
  }
  .row:last-child { border-bottom: 0; }
  .row:focus-visible { outline: 2px solid #2563eb; outline-offset: -2px; }
  .row small { color: #6b7280; }
  .hint { color: #4b5563; font-size: 14px; margin: 0 0 10px; }
  .menu {
    position: fixed; margin: 0; padding: 4px; min-width: 160px;
    background: #fff; border: 1px solid #d9dde3; border-radius: 8px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, .16);
  }
  .menu button {
    display: block; width: 100%; padding: 9px 12px; border: 0; border-radius: 5px;
    background: none; font: inherit; text-align: left; cursor: pointer;
  }
  .menu button:hover, .menu button:focus { background: #e8f0fe; outline: none; }
  #out { margin: 12px 2px 0; color: #374151; }
</style>
</head>
<body>
<p class="hint">Right-click a file, long-press it on a phone, or Tab to it and press Shift+F10 or the Menu key.</p>
<ul class="files">
  <li class="row" tabindex="0" aria-haspopup="menu" data-name="report.pdf">report.pdf <small>2.1 MB</small></li>
  <li class="row" tabindex="0" aria-haspopup="menu" data-name="photo.jpg">photo.jpg <small>840 KB</small></li>
  <li class="row" tabindex="0" aria-haspopup="menu" data-name="notes.txt">notes.txt <small>3 KB</small></li>
</ul>
<p id="out">Nothing picked yet.</p>

<div class="menu" id="menu" role="menu" hidden>
  <button role="menuitem" tabindex="-1">Open</button>
  <button role="menuitem" tabindex="-1">Rename</button>
  <button role="menuitem" tabindex="-1">Delete</button>
</div>

<script>
  const menu = document.getElementById('menu');
  const items = [...menu.querySelectorAll('[role=menuitem]')];
  const out = document.getElementById('out');
  let target = null;    // the row the menu belongs to
  let viaKey = false;   // opened with Shift+F10 or the Menu key?

  function openFor(row, x, y) {
    target = row;
    menu.hidden = false;
    if (x + menu.offsetWidth > innerWidth) x -= menu.offsetWidth;    // flip left
    if (y + menu.offsetHeight > innerHeight) y -= menu.offsetHeight; // flip up
    menu.style.left = Math.max(0, x) + 'px';
    menu.style.top = Math.max(0, y) + 'px';
    items[0].focus();
  }

  function close(returnFocus) {
    if (menu.hidden) return;
    menu.hidden = true;
    if (returnFocus) target.focus();  // back to the row it came from
  }

  document.addEventListener('keydown', (e) => {
    viaKey = e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10');
  }, true);

  document.querySelectorAll('.row').forEach((row) => {
    row.addEventListener('contextmenu', (e) => {
      e.preventDefault();
      if (viaKey) {  // keyboard: open under the row, not at the event's x/y
        const r = row.getBoundingClientRect();
        openFor(row, r.left + 16, r.bottom);
      } else {
        openFor(row, e.clientX, e.clientY);
      }
    });

    // long press, for touch browsers that do not fire contextmenu
    let timer = 0, sx = 0, sy = 0;
    row.addEventListener('pointerdown', (e) => {
      if (e.pointerType !== 'touch') return;
      sx = e.clientX; sy = e.clientY;
      timer = setTimeout(() => openFor(row, sx, sy), 500);
    });
    row.addEventListener('pointermove', (e) => {
      if (Math.hypot(e.clientX - sx, e.clientY - sy) > 10) clearTimeout(timer);  // a scroll, not a press
    });
    row.addEventListener('pointerup', () => clearTimeout(timer));
    row.addEventListener('pointercancel', () => clearTimeout(timer));
  });

  // arrow keys inside the menu
  menu.addEventListener('keydown', (e) => {
    const i = items.indexOf(document.activeElement);
    const n = items.length;
    if (e.key === 'ArrowDown') items[(i + 1) % n].focus();
    else if (e.key === 'ArrowUp') items[(i - 1 + n) % n].focus();
    else if (e.key === 'Home') items[0].focus();
    else if (e.key === 'End') items[n - 1].focus();
    else if (e.key === 'Escape') close(true);
    else if (e.key === 'Tab') { close(true); return; }
    else return;
    e.preventDefault();
  });

  menu.addEventListener('click', (e) => {
    const item = e.target.closest('button');
    if (!item) return;
    out.textContent = item.textContent + ' → ' + target.dataset.name;
    close(true);
  });

  // close on a press outside, and when the page moves under the menu
  document.addEventListener('pointerdown', (e) => {
    if (!menu.contains(e.target)) { viaKey = false; close(false); }
  });
  addEventListener('resize', () => close(false));
  addEventListener('scroll', () => close(false), true);
  addEventListener('blur', () => close(false));
</script>
</body>
</html>
Right-click a file, long-press it on a phone, or Tab to it and press Shift+F10.
  • One openFor(row, x, y) function stores which row the menu belongs to, flips the menu at the edges and focuses the first item.
  • One close(returnFocus) function hides the menu. Escape, Tab and a pick give focus back to the row. A press outside, scrolling and resizing do not.
  • The picked action reads the stored row, so "Rename" knows which file it is for.

If you only need a menu that opens from a button, a dropdown menu is simpler. A context menu adds value when the actions belong to the thing under the cursor.

When it does not work

What you see Cause Fix
Both menus appear preventDefault() is missing Call it first in the listener
The menu is in the wrong place after scrolling The menu is position: absolute Use position: fixed with clientX
The menu is cut off at the edge No edge check Subtract offsetWidth or offsetHeight
The flip does nothing Measured while hidden, so the size was 0 Show the menu, then measure
Right-click is gone on the whole page The listener is on document Put it on the element only
Shift+F10 does nothing The element cannot take focus Add tabindex="0"
Nothing on a phone The browser does not fire contextmenu on long press Add the pointerdown timer
A long press selects text Text selection on the row user-select: none on the row
A popover menu sits in the middle Default popover centring inset: auto and margin: 0

A context menu is hard to show in a screenshot. The point is what happens on a right-click, a long press or Shift+F10, and a picture shows none of that.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can right-click and long-press the rows themselves. If you change the code later, the same link shows the new version.

Questions people ask

Is there an HTML element for a custom context menu?

No. An older contextmenu attribute was dropped from the HTML standard and does not work in current browsers. You listen for the contextmenu event in JavaScript, call preventDefault, and show your own element.

How do I stop the browser's right-click menu from appearing?

Call event.preventDefault() in a contextmenu listener. Put the listener on the element that has the custom menu, not on the whole document, so right-click still works everywhere else on the page.

Can a keyboard user open my custom context menu?

Yes, if the element can take focus. The Menu key, and Shift+F10 on Windows, fire contextmenu on the focused element. Give the element tabindex="0" and place the menu next to it rather than at the event's x and y.

Why does my context menu not open on a phone?

Whether a long press fires contextmenu depends on the browser. Start a timer on pointerdown for touch, cancel it if the finger moves or lifts, and open the menu when it runs out.

Should I use the popover attribute for the menu?

It helps. A popover closes itself on Escape and on a click outside, and it sits above everything else on the page. You still position it yourself with left and top.

Keep reading