/* Nested menus: an item with aria-haspopup="menu" controls a submenu beside it. Hover opens it, so do ArrowRight, Enter and Space;
ArrowLeft and Esc close it. Under 600px submenus open inline below their item. */
.nm-wrap{--nm-ink:#211a15;--nm-muted:#645a52;--nm-line:#ebe4dd;--nm-hover:#f8f1ea;--nm-accent:#c2410c;--nm-danger:#b42318;--nm-danger-bg:#fef3f2;font-size:.875rem}
.nm{position:relative;display:inline-block}
.nm-btn{display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 12px;border:1px solid #d9d0c7;border-radius:10px;background:#fff;color:var(--nm-ink);
font:inherit;font-weight:600;cursor:pointer;box-shadow:0 1px 2px rgba(40,24,10,.06);transition:background-color .15s}
.nm-btn:hover,.nm-btn[aria-expanded="true"]{background:var(--nm-hover)}
.nm-btn svg{width:16px;height:16px;flex:none;color:var(--nm-muted);transition:rotate .2s}
.nm-btn[aria-expanded="true"] .nm-chev{rotate:180deg}
.nm-btn:focus-visible{outline:2px solid var(--nm-accent);outline-offset:2px}
.nm-menu{position:absolute;z-index:20;top:calc(100% + 6px);left:0;min-width:216px;padding:6px;background:#fff;border:1px solid var(--nm-line);border-radius:12px;
box-shadow:0 1px 2px rgba(40,24,10,.05),0 16px 36px -12px rgba(40,24,10,.26);animation:nm-in .14s ease-out}
.nm-menu.end{left:auto;right:0}
.nm-menu[hidden]{display:none}
@keyframes nm-in{from{opacity:0;scale:.97}}
.nm-item{display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;border:0;border-radius:8px;background:none;color:var(--nm-ink);font:inherit;font-weight:500;
line-height:1.4;text-align:left;text-decoration:none;white-space:nowrap;cursor:pointer}
.nm-item>svg{width:16px;height:16px;flex:none;color:var(--nm-muted)}
.nm-item:hover,.nm-item:focus{background:var(--nm-hover);outline:none}
.nm-item:focus-visible{outline:2px solid var(--nm-accent);outline-offset:-2px}
.nm-item.danger,.nm-item.danger>svg{color:var(--nm-danger)}
.nm-item.danger:hover,.nm-item.danger:focus{background:var(--nm-danger-bg)}
.nm-kbd{margin-left:auto;padding-left:18px;color:var(--nm-muted);font:500 .75rem ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.nm-sep{height:1px;margin:6px -6px;background:var(--nm-line)}
.nm-sub{position:relative}
.nm-sub>.nm-menu{top:-7px;left:calc(100% + 2px);margin:0}
.nm-menu.start .nm-sub>.nm-menu{left:auto;right:calc(100% + 2px)}
.nm-item[aria-expanded="true"]{background:var(--nm-hover)}
.nm-subicon{width:14px;height:14px;margin-left:auto;padding-left:0;flex:none;color:var(--nm-muted)}
.nm-row{display:flex;align-items:center;gap:12px;padding:10px 10px 10px 14px;background:#fff;border:1px solid var(--nm-line);border-radius:12px;box-shadow:0 1px 2px rgba(40,24,10,.05)}
.nm-row>svg{width:22px;height:22px;flex:none;color:#c2410c}
.nm-row b{display:block;color:var(--nm-ink);font-weight:600;line-height:1.3}
.nm-row small{display:block;color:var(--nm-muted);font-size:.8rem}
.nm-row .nm{margin-left:auto}
.nm-dots{width:36px;height:36px;padding:0;justify-content:center}
@media (max-width:600px){
.nm-sub>.nm-menu,.nm-menu.start .nm-sub>.nm-menu{position:static;min-width:0;margin:2px 0 4px 16px;padding:2px 0 2px 8px;border:0;border-left:2px solid var(--nm-line);
border-radius:0;box-shadow:none;animation:none;transform:none}
.nm-item[aria-expanded="true"]>.nm-subicon{rotate:90deg}
.nm-menu.start .nm-item[aria-expanded="true"]>.nm-subicon{rotate:-90deg}
.nm-row small{display:none}
}
@media (prefers-reduced-motion:reduce){.nm-menu{animation:none}.nm-btn,.nm-btn svg{transition:none}}
JavaScript
// Menu button (WAI-ARIA APG pattern). A trigger has aria-haspopup="menu", aria-expanded and aria-controls="<menu id>".
// Click, Enter, Space or ArrowDown opens the menu (ArrowUp opens it on the last item). Inside: ArrowUp/ArrowDown move,
// Home/End jump, typing a letter jumps to the next item that starts with it, Esc closes and returns focus to the trigger,
// Tab or a click outside closes. Choosing an item closes the menu; checkbox items stay open so you can tick several.
(() => {
const OPEN = '[aria-haspopup="menu"][aria-expanded="true"]';
const menuOf = t => document.getElementById(t.getAttribute('aria-controls'));
const ownerOf = m => m && document.querySelector(`[aria-controls="${m.id}"][aria-expanded="true"]`);
const itemsOf = m => [...m.querySelectorAll('[role^="menuitem"]')]
.filter(i => i.closest('[role="menu"]') === m && i.getAttribute('aria-disabled') !== 'true');
function close(t, focus) {
const m = menuOf(t);
m.querySelectorAll(OPEN).forEach(s => close(s));
t.setAttribute('aria-expanded', 'false');
m.hidden = true;
if (focus) t.focus();
}
function open(t, at) {
const level = t.closest('[role="menu"]');
document.querySelectorAll(OPEN).forEach(o => { if (o !== t && o.closest('[role="menu"]') === level) close(o); });
t.setAttribute('aria-expanded', 'true');
menuOf(t).hidden = false;
if (at !== undefined) itemsOf(menuOf(t)).at(at)?.focus();
}
const closeAll = target => document.querySelectorAll(OPEN).forEach(t => {
if (!t.closest('[role="menu"]') && !t.contains(target) && !menuOf(t).contains(target)) close(t);
});
document.addEventListener('click', e => {
const t = e.target.closest('[aria-haspopup="menu"][aria-controls]:not([data-context])');
if (t) {
if (!t.closest('[role="menu"]') && t.getAttribute('aria-expanded') === 'true') close(t);
else open(t, e.detail === 0 ? 0 : undefined); // keyboard clicks (Enter/Space) move focus into the menu
return;
}
const item = e.target.closest('[role^="menuitem"]');
if (item && item.getAttribute('aria-disabled') !== 'true') {
const role = item.getAttribute('role');
if (role === 'menuitemcheckbox') { item.setAttribute('aria-checked', item.getAttribute('aria-checked') !== 'true'); return; }
if (role === 'menuitemradio') {
const set = item.closest('[role="group"]') || item.closest('[role="menu"]');
set.querySelectorAll('[role="menuitemradio"]').forEach(r => r.setAttribute('aria-checked', r === item));
}
let t = ownerOf(item.closest('[role="menu"]'));
while (t && t.closest('[role="menu"]')) t = ownerOf(t.closest('[role="menu"]'));
if (t) close(t, true);
return;
}
closeAll(e.target);
});
document.addEventListener('keydown', e => {
const t = e.target.closest('[aria-haspopup="menu"][aria-controls]:not([data-context])');
if (t && !t.closest('[role="menu"]')) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); open(t, e.key === 'ArrowDown' ? 0 : -1); }
else if (e.key === 'Escape' && t.getAttribute('aria-expanded') === 'true') close(t);
return;
}
const m = e.target.closest('[role="menu"]');
if (!m) return;
const items = itemsOf(m), i = items.indexOf(e.target), owner = ownerOf(m);
let next;
switch (e.key) {
case 'ArrowDown': next = items[(i + 1) % items.length]; break;
case 'ArrowUp': next = items.at(i <= 0 ? -1 : i - 1); break;
case 'Home': next = items[0]; break;
case 'End': next = items.at(-1); break;
case 'ArrowRight': if (e.target.matches('[aria-haspopup="menu"]')) { e.preventDefault(); open(e.target, 0); } return;
case 'ArrowLeft': if (!owner || !owner.closest('[role="menu"]')) return; // falls through: close the submenu
case 'Escape': if (owner) { e.preventDefault(); close(owner, true); } return;
case 'Tab': closeAll(null); return;
case ' ': if (e.target.tagName === 'A') { e.preventDefault(); e.target.click(); } return;
default:
if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
next = [...items.slice(i + 1), ...items.slice(0, i + 1)]
.find(x => x.textContent.trim().toLowerCase().startsWith(e.key.toLowerCase()));
}
e.preventDefault();
next?.focus();
});
// Pointer: resting on an item with a submenu opens it; leaving closes it after a short delay,
// so a diagonal path into the submenu doesn't snap it shut.
document.querySelectorAll('[role="menu"] [aria-haspopup="menu"]').forEach(t => {
let timer;
t.parentElement.addEventListener('pointerenter', e => { if (e.pointerType !== 'touch') { clearTimeout(timer); open(t); } });
t.parentElement.addEventListener('pointerleave', e => { if (e.pointerType !== 'touch') timer = setTimeout(() => close(t), 250); });
});
})();
// Submenus for Bootstrap dropdowns (Bootstrap 5 has none built in). An item with aria-haspopup="menu" opens the
// .dropdown-menu after it on hover, click, Enter or ArrowRight; ArrowLeft or Esc closes it and returns focus to the item.
const subItems = sub => [...sub.querySelectorAll('.dropdown-item')].filter(i => i.closest('.dropdown-menu') === sub && !i.classList.contains('disabled'));
document.querySelectorAll('.nm-sub > [aria-haspopup="menu"]').forEach(item => {
const sub = item.nextElementSibling;
const open = () => {
const level = item.closest('.dropdown-menu');
level.querySelectorAll('[aria-haspopup="menu"][aria-expanded="true"]').forEach(o => { if (o !== item && o.closest('.dropdown-menu') === level) o.closeSub(); });
item.setAttribute('aria-expanded', 'true');
sub.classList.add('show');
};
item.closeSub = focus => {
sub.querySelectorAll('[aria-haspopup="menu"][aria-expanded="true"]').forEach(s => s.closeSub());
item.setAttribute('aria-expanded', 'false');
sub.classList.remove('show');
if (focus) item.focus();
};
let timer;
item.parentElement.addEventListener('pointerenter', e => { if (e.pointerType !== 'touch') { clearTimeout(timer); open(); } });
item.parentElement.addEventListener('pointerleave', e => { if (e.pointerType !== 'touch') timer = setTimeout(() => item.closeSub(), 250); });
item.addEventListener('click', e => {
e.stopPropagation(); // keep Bootstrap from closing the whole dropdown
open();
if (e.detail === 0) subItems(sub)[0].focus(); // Enter or Space
});
item.addEventListener('keydown', e => { if (e.key === 'ArrowRight') { e.preventDefault(); open(); subItems(sub)[0].focus(); } });
});
// Keys inside a submenu. Bootstrap listens on document in the capture phase and only knows top-level menus,
// so this listens on window (capture runs there first) and stops the event once it has handled it.
window.addEventListener('keydown', e => {
const t = e.target instanceof Element ? e.target : null;
if (!t) return;
if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && t.matches('.nm-sub > [aria-expanded="true"]')) t.closeSub(); // step past it
const sub = t.closest('.nm-sub > .dropdown-menu');
if (!sub || !['ArrowDown', 'ArrowUp', 'Home', 'End', 'ArrowLeft', 'Escape'].includes(e.key)) return;
e.preventDefault();
e.stopPropagation();
if (e.key === 'ArrowLeft' || e.key === 'Escape') return sub.previousElementSibling.closeSub(true);
const list = subItems(sub), i = list.indexOf(t);
if (i > -1 && t.matches('[aria-expanded="true"]')) t.closeSub();
({ ArrowDown: list[(i + 1) % list.length], ArrowUp: list.at(i <= 0 ? -1 : i - 1), Home: list[0], End: list.at(-1) })[e.key].focus();
}, true);
// Closing the dropdown closes every submenu inside it.
document.addEventListener('hide.bs.dropdown', e => {
e.target.parentElement.querySelectorAll('.nm-sub > [aria-expanded="true"]').forEach(s => s.closeSub());
});
// Menu button (WAI-ARIA APG pattern). A trigger has aria-haspopup="menu", aria-expanded and aria-controls="<menu id>".
// Click, Enter, Space or ArrowDown opens the menu (ArrowUp opens it on the last item). Inside: ArrowUp/ArrowDown move,
// Home/End jump, typing a letter jumps to the next item that starts with it, Esc closes and returns focus to the trigger,
// Tab or a click outside closes. Choosing an item closes the menu; checkbox items stay open so you can tick several.
(() => {
const OPEN = '[aria-haspopup="menu"][aria-expanded="true"]';
const menuOf = t => document.getElementById(t.getAttribute('aria-controls'));
const ownerOf = m => m && document.querySelector(`[aria-controls="${m.id}"][aria-expanded="true"]`);
const itemsOf = m => [...m.querySelectorAll('[role^="menuitem"]')]
.filter(i => i.closest('[role="menu"]') === m && i.getAttribute('aria-disabled') !== 'true');
function close(t, focus) {
const m = menuOf(t);
m.querySelectorAll(OPEN).forEach(s => close(s));
t.setAttribute('aria-expanded', 'false');
m.hidden = true;
if (focus) t.focus();
}
function open(t, at) {
const level = t.closest('[role="menu"]');
document.querySelectorAll(OPEN).forEach(o => { if (o !== t && o.closest('[role="menu"]') === level) close(o); });
t.setAttribute('aria-expanded', 'true');
menuOf(t).hidden = false;
if (at !== undefined) itemsOf(menuOf(t)).at(at)?.focus();
}
const closeAll = target => document.querySelectorAll(OPEN).forEach(t => {
if (!t.closest('[role="menu"]') && !t.contains(target) && !menuOf(t).contains(target)) close(t);
});
document.addEventListener('click', e => {
const t = e.target.closest('[aria-haspopup="menu"][aria-controls]:not([data-context])');
if (t) {
if (!t.closest('[role="menu"]') && t.getAttribute('aria-expanded') === 'true') close(t);
else open(t, e.detail === 0 ? 0 : undefined); // keyboard clicks (Enter/Space) move focus into the menu
return;
}
const item = e.target.closest('[role^="menuitem"]');
if (item && item.getAttribute('aria-disabled') !== 'true') {
const role = item.getAttribute('role');
if (role === 'menuitemcheckbox') { item.setAttribute('aria-checked', item.getAttribute('aria-checked') !== 'true'); return; }
if (role === 'menuitemradio') {
const set = item.closest('[role="group"]') || item.closest('[role="menu"]');
set.querySelectorAll('[role="menuitemradio"]').forEach(r => r.setAttribute('aria-checked', r === item));
}
let t = ownerOf(item.closest('[role="menu"]'));
while (t && t.closest('[role="menu"]')) t = ownerOf(t.closest('[role="menu"]'));
if (t) close(t, true);
return;
}
closeAll(e.target);
});
document.addEventListener('keydown', e => {
const t = e.target.closest('[aria-haspopup="menu"][aria-controls]:not([data-context])');
if (t && !t.closest('[role="menu"]')) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); open(t, e.key === 'ArrowDown' ? 0 : -1); }
else if (e.key === 'Escape' && t.getAttribute('aria-expanded') === 'true') close(t);
return;
}
const m = e.target.closest('[role="menu"]');
if (!m) return;
const items = itemsOf(m), i = items.indexOf(e.target), owner = ownerOf(m);
let next;
switch (e.key) {
case 'ArrowDown': next = items[(i + 1) % items.length]; break;
case 'ArrowUp': next = items.at(i <= 0 ? -1 : i - 1); break;
case 'Home': next = items[0]; break;
case 'End': next = items.at(-1); break;
case 'ArrowRight': if (e.target.matches('[aria-haspopup="menu"]')) { e.preventDefault(); open(e.target, 0); } return;
case 'ArrowLeft': if (!owner || !owner.closest('[role="menu"]')) return; // falls through: close the submenu
case 'Escape': if (owner) { e.preventDefault(); close(owner, true); } return;
case 'Tab': closeAll(null); return;
case ' ': if (e.target.tagName === 'A') { e.preventDefault(); e.target.click(); } return;
default:
if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
next = [...items.slice(i + 1), ...items.slice(0, i + 1)]
.find(x => x.textContent.trim().toLowerCase().startsWith(e.key.toLowerCase()));
}
e.preventDefault();
next?.focus();
});
// Pointer: resting on an item with a submenu opens it; leaving closes it after a short delay,
// so a diagonal path into the submenu doesn't snap it shut.
document.querySelectorAll('[role="menu"] [aria-haspopup="menu"]').forEach(t => {
let timer;
t.parentElement.addEventListener('pointerenter', e => { if (e.pointerType !== 'touch') { clearTimeout(timer); open(t); } });
t.parentElement.addEventListener('pointerleave', e => { if (e.pointerType !== 'touch') timer = setTimeout(() => close(t), 250); });
});
})();
About this set
Dropdowns with submenus that work with a mouse and with the keyboard. Submenus on hover and with arrow keys is a document editor’s Insert menu, where Image, Table and Chart each open a submenu, alongside Divider, Page break and Special character. Three levels, opening to the left is the more menu on a file row: Move to opens a list of folders, and Design opens a third level, all cascading leftwards because the button sits at the right edge. Resting the pointer on an item opens its submenu and a short delay stops it snapping shut on a diagonal path. From the keyboard, ArrowRight, Enter or Space opens a submenu on its first item, ArrowLeft or Esc closes it and returns focus to its parent item, and ArrowUp and ArrowDown stay within one level. Under 600px every submenu opens inline below its item, indented with a guide line, so nothing runs off a phone screen. Bootstrap 5 has no nested dropdowns, so the Bootstrap version uses its Dropdown plugin for the first level and a small script for the rest.
What’s included
Submenus open on hover with a close delay for diagonal paths
ArrowRight, ArrowLeft and Esc move between levels
Three levels that cascade to the left from a right-edge button
Inline, indented submenus under 600px
Bootstrap Dropdown plus a small submenu script
Accessibility
Items that open a submenu carry aria-haspopup="menu", aria-expanded and aria-controls, and every submenu is a labelled role="menu". Keyboard support follows the WAI-ARIA menu pattern, including returning focus to the parent item when a submenu closes. Focus outlines are visible on every item, and the open animation and chevron rotation stop under prefers-reduced-motion.
Customise it
Colours are the --nm-* custom properties on .nm-wrap. Add the start class to a menu to make its submenus open to the left, and change the 600px breakpoint to switch to inline submenus at another width. In Tailwind edit the hex values and the max-[600px]: utilities.