Right-click almost anything and a little menu pops up. Cut, Copy, Paste, a submenu, a red Delete at the bottom. We use it a hundred times a day and never think about it. So for Day 29 of DesignFromZero I rebuilt it in vanilla JS, and it turned out to be one of those components that looks trivial and is quietly full of edge cases.
You can play with the finished thing here: https://dev48v.infy.uk/design/day29-context-menu.html
The one line that starts everything
Right-clicking fires a contextmenu event, and by default the browser answers it with its own menu. To show yours instead, you catch that event and cancel the default:
surface.addEventListener("contextmenu", e => {
e.preventDefault(); // no native menu
openRoot(e.clientX, e.clientY); // yours, at the cursor
});
That's the whole idea. The interesting part is what openRoot has to get right.
Positioning is where it gets real
The naive version sets left and top to the cursor and calls it done. It works until you right-click near the right edge and half your menu is off-screen.
The fix is flip-then-clamp. First you have to measure the menu, which means it must already be in the DOM (a detached element has zero size), so you append it hidden and read getBoundingClientRect(). Then: if opening at the cursor would overflow the right edge, open it leftward instead. Same for the bottom edge, flip it upward. Finally, clamp so it can never sit outside a small margin.
function placeMenu(x, y, mw, mh, vw, vh, m = 8){
let px = x;
if (x + mw + m > vw) px = x - mw; // flip left
px = Math.max(m, Math.min(px, vw - mw - m)); // clamp
let py = y;
if (y + mh + m > vh) py = y - mh; // flip up
py = Math.max(m, Math.min(py, vh - mh - m));
return { x: px, y: py };
}
Because the menu is position: fixed, clientX/clientY map straight onto left/top with no scroll math. Use absolute and you'll be adding scroll offsets and fighting whichever ancestor happens to be positioned.
Describe the menu as data, not markup
I don't hand-write a <div> per row. The menu is an array of plain objects with icon, label, shortcut, and flags like sep, disabled, danger, submenu. A tiny render function turns that into elements. Now the structure is data you can reorder, gate by permission, or generate. Each visual type carries meaning too: a separator groups related actions, a greyed row shows something that exists but isn't available right now, and the red destructive item sits last so nobody hits Delete by reflex.
Keyboard, focus, and the roving tabindex
A menu you can only use with a mouse is broken for a lot of people. The pattern is roving tabindex: every item has tabindex="-1", and the code moves real focus to exactly one active item at a time. The highlight and the focused element are always the same row, so a screen reader and a sighted user are looking at the same place.
One keydown handler, pointed at the deepest open menu, does the rest. Up and Down move and wrap around the ends, Home and End jump, Enter activates, Escape closes one level, and Tab is trapped so focus can't leak to the page behind. Every handled key calls preventDefault() so arrows don't also scroll the page.
Submenus are a stack
Nesting is the reason to build your own menu at all, because the native one can't do a rich "Share" fly-out. I keep open menus in a stack: the root at the bottom, each submenu pushed on top, and the top of the stack is always the keyboard target. A submenu opens next to its parent item (and flips left if it would overflow), on hover, on Enter, or on ArrowRight. ArrowLeft pops it and returns focus to the parent row.
Making it go away
A menu that won't close is a trap, so there are four exits: a pointer-down outside any menu, Escape (one level at a time), scroll (the anchor point moved), and window blur (you alt-tabbed away). Capture-phase listeners make them fire first.
Don't forget touch
Phones have no right-click. The equivalent is a long-press, so I start a ~500ms timer on touchstart, cancel it if the finger moves or lifts early, and fire the same openRoot on timeout. One menu, three input methods.
Roles for accessibility (menu, menuitem, separator, aria-haspopup, aria-expanded), the flip math, the focus model, the dismiss listeners. That's the whole thing, in about 130 lines and no dependencies. Next up is a sortable data table.
Top comments (0)