UI Components Medium
Context Menu
Right-click context menu with nested sub-menus, keyboard navigation, icons, dividers, and disabled items.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #050910;
--card: #0d1117;
--card-raised: #111827;
--border: rgba(255, 255, 255, 0.08);
--text: #f2f6ff;
--muted: #475569;
--accent: #38bdf8;
--danger: #f87171;
--radius: 10px;
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
user-select: none;
}
/* โโ Demo area โโ */
.page {
width: 100%;
max-width: 700px;
}
.demo-area {
width: 100%;
height: 380px;
border: 1.5px dashed rgba(255, 255, 255, 0.12);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: context-menu;
outline: none;
transition: border-color 0.2s, background 0.2s;
}
.demo-area:hover,
.demo-area:focus-visible {
border-color: rgba(56, 189, 248, 0.3);
background: rgba(56, 189, 248, 0.02);
}
.demo-area__inner {
text-align: center;
pointer-events: none;
}
.demo-area__icon {
font-size: 2.5rem;
display: block;
margin-bottom: 1rem;
opacity: 0.5;
}
.demo-area__label {
font-size: 1rem;
color: var(--muted);
font-weight: 500;
}
.demo-area__sub {
font-size: 0.8rem;
color: rgba(71, 85, 105, 0.6);
margin-top: 0.3rem;
}
/* โโ Context menu โโ */
.context-menu {
position: fixed;
background: var(--card-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.3rem;
min-width: 190px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
z-index: 9999;
animation: menu-in 0.12s ease;
}
@keyframes menu-in {
from {
opacity: 0;
transform: scale(0.96) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* โโ Menu items โโ */
.menu-item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.5rem 0.65rem;
border-radius: 6px;
background: none;
border: none;
color: var(--text);
font-size: 0.855rem;
cursor: pointer;
text-align: left;
position: relative;
transition: background 0.15s;
}
.menu-item:hover,
.menu-item:focus-visible,
.menu-item.focused {
background: rgba(255, 255, 255, 0.05);
outline: none;
}
.menu-item__icon {
font-size: 0.95rem;
width: 18px;
text-align: center;
opacity: 0.75;
flex-shrink: 0;
}
.menu-item__label {
flex: 1;
}
.menu-item__shortcut {
font-size: 0.75rem;
color: var(--muted);
margin-left: auto;
flex-shrink: 0;
}
.menu-item__chevron {
font-size: 1.1rem;
color: var(--muted);
margin-left: auto;
}
/* โโ Disabled โโ */
.menu-item--disabled {
opacity: 0.38;
cursor: not-allowed;
pointer-events: none;
}
/* โโ Danger โโ */
.menu-item--danger {
color: var(--danger);
}
.menu-item--danger .menu-item__shortcut {
color: rgba(248, 113, 113, 0.5);
}
/* โโ Sub-menu โโ */
.menu-item--sub {
cursor: default;
}
.submenu {
position: absolute;
left: calc(100% + 4px);
top: -0.3rem;
background: var(--card-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.3rem;
min-width: 160px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
display: none;
z-index: 10000;
animation: menu-in 0.12s ease;
}
.menu-item--sub:hover .submenu,
.menu-item--sub.sub-open .submenu {
display: block;
}
/* โโ Divider โโ */
.menu-divider {
height: 1px;
background: var(--border);
margin: 0.3rem 0;
}(function () {
"use strict";
const area = document.getElementById("demo-area");
const menu = document.getElementById("context-menu");
let isOpen = false;
let focusIdx = -1;
// โโ Show โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function show(x, y) {
menu.style.display = "block";
isOpen = true;
focusIdx = -1;
// Clamp to viewport
const vw = window.innerWidth;
const vh = window.innerHeight;
menu.style.left = "0";
menu.style.top = "0";
const mw = menu.offsetWidth;
const mh = menu.offsetHeight;
menu.style.left = Math.min(x, vw - mw - 8) + "px";
menu.style.top = Math.min(y, vh - mh - 8) + "px";
// Update aria-expanded on sub-menu triggers
getItems().forEach(function (el) {
if (el.classList.contains("menu-item--sub")) {
el.setAttribute("aria-expanded", "false");
}
});
}
function hide() {
menu.style.display = "none";
isOpen = false;
focusIdx = -1;
clearSubMenus();
}
function clearSubMenus() {
menu.querySelectorAll(".menu-item--sub").forEach(function (el) {
el.classList.remove("sub-open");
el.setAttribute("aria-expanded", "false");
});
}
// โโ Events โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
area.addEventListener("contextmenu", function (e) {
e.preventDefault();
show(e.clientX, e.clientY);
});
// Long-press for mobile
let longPressTimer = null;
area.addEventListener(
"touchstart",
function (e) {
const t = e.touches[0];
longPressTimer = setTimeout(function () {
show(t.clientX, t.clientY);
}, 500);
},
{ passive: true }
);
area.addEventListener(
"touchend",
function () {
clearTimeout(longPressTimer);
},
{ passive: true }
);
area.addEventListener(
"touchmove",
function () {
clearTimeout(longPressTimer);
},
{ passive: true }
);
document.addEventListener("click", function (e) {
if (!menu.contains(e.target)) hide();
});
document.addEventListener("keydown", function (e) {
if (!isOpen) return;
switch (e.key) {
case "Escape":
e.preventDefault();
hide();
break;
case "ArrowDown":
e.preventDefault();
moveFocus(1);
break;
case "ArrowUp":
e.preventDefault();
moveFocus(-1);
break;
case "ArrowRight": {
e.preventDefault();
const focused = getFocusedItem();
if (focused && focused.classList.contains("menu-item--sub")) {
focused.classList.add("sub-open");
focused.setAttribute("aria-expanded", "true");
const sub = focused.querySelector(".submenu");
if (sub) {
const first = sub.querySelector(".menu-item");
if (first) first.focus();
}
}
break;
}
case "ArrowLeft":
e.preventDefault();
clearSubMenus();
break;
case "Enter":
case " ": {
e.preventDefault();
const focused = getFocusedItem();
if (focused) focused.click();
break;
}
}
});
// โโ Item actions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
menu.querySelectorAll(".menu-item[data-action]").forEach(function (btn) {
btn.addEventListener("click", function () {
const action = btn.dataset.action;
const labels = {
cut: "Cut",
copy: "Copy",
"copy-link": "Link copied!",
email: "Opening email clientโฆ",
twitter: "Opening Twitterโฆ",
delete: "Deleted!",
};
console.log("Action:", action);
const label = labels[action] || action;
showFeedback(label);
hide();
});
});
// Sub-menu open/close on hover
menu.querySelectorAll(".menu-item--sub").forEach(function (el) {
el.addEventListener("mouseenter", function () {
clearSubMenus();
el.classList.add("sub-open");
el.setAttribute("aria-expanded", "true");
});
});
// โโ Keyboard focus helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function getItems() {
return Array.from(menu.querySelectorAll(".menu-item:not(.menu-item--disabled)"));
}
function getFocusedItem() {
return menu.querySelector(".menu-item:focus, .menu-item.focused");
}
function moveFocus(dir) {
const items = getItems();
if (!items.length) return;
focusIdx = (focusIdx + dir + items.length) % items.length;
items[focusIdx].focus();
}
// โโ Feedback toast โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function showFeedback(msg) {
const el = document.createElement("div");
Object.assign(el.style, {
position: "fixed",
bottom: "1.5rem",
left: "50%",
transform: "translateX(-50%)",
background: "#0d1117",
border: "1px solid rgba(255,255,255,0.08)",
color: "#f2f6ff",
padding: "0.5rem 1.25rem",
borderRadius: "999px",
fontSize: "0.85rem",
zIndex: "99999",
animation: "menu-in 0.15s ease",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
});
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () {
el.remove();
}, 2000);
}
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Context Menu</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<div class="demo-area" id="demo-area" tabindex="0" aria-label="Right-click demo area">
<div class="demo-area__inner">
<span class="demo-area__icon">๐ฑ</span>
<p class="demo-area__label">Right-click anywhere in this area</p>
<p class="demo-area__sub">Or long-press on mobile</p>
</div>
</div>
</div>
<!-- Context Menu -->
<nav class="context-menu" id="context-menu" role="menu" aria-label="Context actions" style="display:none">
<!-- Cut -->
<button class="menu-item" role="menuitem" data-action="cut">
<span class="menu-item__icon">โ</span>
<span class="menu-item__label">Cut</span>
<span class="menu-item__shortcut">โX</span>
</button>
<!-- Copy -->
<button class="menu-item" role="menuitem" data-action="copy">
<span class="menu-item__icon">โ</span>
<span class="menu-item__label">Copy</span>
<span class="menu-item__shortcut">โC</span>
</button>
<!-- Paste (disabled) -->
<button class="menu-item menu-item--disabled" role="menuitem" aria-disabled="true" data-action="paste">
<span class="menu-item__icon">โ</span>
<span class="menu-item__label">Paste</span>
<span class="menu-item__shortcut">โV</span>
</button>
<div class="menu-divider" role="separator"></div>
<!-- Share โ sub-menu -->
<div class="menu-item menu-item--sub" role="menuitem" aria-haspopup="true" aria-expanded="false" tabindex="0">
<span class="menu-item__icon">โ</span>
<span class="menu-item__label">Share</span>
<span class="menu-item__chevron">โบ</span>
<nav class="submenu" role="menu" aria-label="Share options">
<button class="menu-item" role="menuitem" data-action="copy-link">
<span class="menu-item__icon">๐</span>
<span class="menu-item__label">Copy link</span>
</button>
<button class="menu-item" role="menuitem" data-action="email">
<span class="menu-item__icon">โ</span>
<span class="menu-item__label">Email</span>
</button>
<button class="menu-item" role="menuitem" data-action="twitter">
<span class="menu-item__icon">๐</span>
<span class="menu-item__label">Twitter / X</span>
</button>
</nav>
</div>
<div class="menu-divider" role="separator"></div>
<!-- Delete (destructive) -->
<button class="menu-item menu-item--danger" role="menuitem" data-action="delete">
<span class="menu-item__icon">๐</span>
<span class="menu-item__label">Delete</span>
<span class="menu-item__shortcut">โซ</span>
</button>
</nav>
<script src="script.js"></script>
</body>
</html>Context Menu
A right-click context menu with nested sub-menus, icon support, dividers, destructive items, and full keyboard navigation. No dependencies.
Features
- Opens at cursor position, clamped to viewport bounds
- Nested sub-menu opens on hover or right-arrow key, closes on left-arrow
- Disabled items are non-interactive
- Destructive (Delete) item styled in red
- Closes on outside click, left-click elsewhere, or
Escape
How it works
contextmenuevent is intercepted on the demo area;preventDefault()suppresses the browser menu- The menu is positioned with
position: fixedatevent.clientX / clientY, then clamped so it never overflows the viewport - Sub-menus use
position: absoluterelative to their parent item, opening to the right - Keyboard:
ArrowDown/ArrowUpmove focus between items;ArrowRightopens a sub-menu;ArrowLeftcloses it;Enteractivates the focused item
Accessibility
- Menu uses
role="menu", items userole="menuitem" - Disabled items carry
aria-disabled="true" - Sub-menu trigger items have
aria-haspopup="true"andaria-expanded