Open demo

CSS code

HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <symbol id="i-chev-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol>
  <symbol id="i-chev-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></symbol>
  <symbol id="i-chev-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m15 6-6 6 6 6"/></symbol>
  <symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z"/><path d="m13.5 6.5 4 4"/></symbol>
  <symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="8.5" y="8.5" width="12" height="12" rx="2"/><path d="M15.5 8.5V5a1.5 1.5 0 0 0-1.5-1.5H5A1.5 1.5 0 0 0 3.5 5v9A1.5 1.5 0 0 0 5 15.5h3.5"/></symbol>
  <symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 7a1.5 1.5 0 0 1 1.5-1.5h4.3l2 2.5H19a1.5 1.5 0 0 1 1.5 1.5v8.5A1.5 1.5 0 0 1 19 19.5H5A1.5 1.5 0 0 1 3.5 18z"/></symbol>
  <symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M10 11v6M14 11v6M5.5 7l1 12a1.5 1.5 0 0 0 1.5 1.5h8a1.5 1.5 0 0 0 1.5-1.5l1-12M9 7V4.5h6V7"/></symbol>
  <symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 14a4 4 0 0 0 5.7 0l3.1-3.1a4 4 0 0 0-5.7-5.7L11.5 6.8"/><path d="M14 10a4 4 0 0 0-5.7 0l-3.1 3.1a4 4 0 0 0 5.7 5.7l1.6-1.6"/></symbol>
  <symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V5M7 9.5l5-5 5 5M5 19.5h14"/></symbol>
  <symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v12a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1z"/><path d="M13.5 3.5v4h4"/></symbol>
  <symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol>
  <symbol id="i-dots" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="5.5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="18.5" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
  <symbol id="i-image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><circle cx="9" cy="10" r="1.8"/><path d="m20.5 16-5-5-9 8.5"/></symbol>
  <symbol id="i-table" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M3.5 9.5h17M3.5 14.5h17M9.5 9.5v10"/></symbol>
  <symbol id="i-chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v16h16"/><path d="M8.5 16v-4M12.5 16V8M16.5 16v-6"/></symbol>
  <symbol id="i-line" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v16h16"/><path d="m7.5 14.5 3.5-4 3 2.5 4.5-6"/></symbol>
  <symbol id="i-pie" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5a8.5 8.5 0 1 0 8.5 8.5H12z"/><path d="M15 3.9A8.5 8.5 0 0 1 20.1 9H15z"/></symbol>
  <symbol id="i-minus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16"/></symbol>
  <symbol id="i-pagebreak" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5v5h12v-5M6 20.5v-5h12v5M3.5 12h2M9 12h2M13 12h2M18.5 12h2"/></symbol>
  <symbol id="i-omega" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19.5h4.5v-2.8A6.5 6.5 0 1 1 14.5 16.7v2.8H19"/></symbol>
  <symbol id="i-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m12 4 2.5 5 5.5.8-4 3.9.9 5.5L12 16.6l-4.9 2.6.9-5.5-4-3.9L9.5 9z"/></symbol>
  <symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3"/></symbol>
  <symbol id="i-move" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h9M9.5 8.5 13 12l-3.5 3.5"/><path d="M13 5.5h5.5a1.5 1.5 0 0 1 1.5 1.5v10a1.5 1.5 0 0 1-1.5 1.5H13"/></symbol>
</svg>
<div class="full"><span class="lbl">Submenus on hover and with arrow keys</span>
<div class="stage tall" style="--stage-h:430px">
<div class="nm-wrap">
<div class="nm">
<button class="nm-btn" id="nm-a-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-a"><svg aria-hidden="true"><use href="#i-plus"/></svg>Insert<svg class="nm-chev" aria-hidden="true"><use href="#i-chev-down"/></svg></button>
<div class="nm-menu" role="menu" id="nm-a" aria-labelledby="nm-a-btn" hidden>
<div class="nm-sub" role="none">
<button type="button" class="nm-item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-a-img"><svg aria-hidden="true"><use href="#i-image"/></svg><span>Image</span><svg class="nm-subicon" aria-hidden="true"><use href="#i-chev-right"/></svg></button>
<div class="nm-menu" role="menu" id="nm-a-img" aria-label="Image" hidden>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-upload"/></svg>Upload from computer</button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-link"/></svg>By URL</button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-star"/></svg>Stock photos</button>
</div>
</div>
<div class="nm-sub" role="none">
<button type="button" class="nm-item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-a-tbl"><svg aria-hidden="true"><use href="#i-table"/></svg><span>Table</span><svg class="nm-subicon" aria-hidden="true"><use href="#i-chev-right"/></svg></button>
<div class="nm-menu" role="menu" id="nm-a-tbl" aria-label="Table" hidden>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-table"/></svg>Blank table</button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-copy"/></svg>From a template…</button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-upload"/></svg>Import CSV</button>
</div>
</div>
<div class="nm-sub" role="none">
<button id="nm-a-chart" type="button" class="nm-item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-a-chart-menu"><svg aria-hidden="true"><use href="#i-chart"/></svg><span>Chart</span><svg class="nm-subicon" aria-hidden="true"><use href="#i-chev-right"/></svg></button>
<div class="nm-menu" role="menu" id="nm-a-chart-menu" aria-label="Chart" hidden>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-chart"/></svg>Bar chart</button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-line"/></svg>Line chart</button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-pie"/></svg>Pie chart</button>
<div class="nm-sep" role="separator"></div>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-table"/></svg>From a spreadsheet…</button>
</div>
</div>
<div class="nm-sep" role="separator"></div>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-minus"/></svg>Divider</button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1" aria-keyshortcuts="Meta+Enter"><svg aria-hidden="true"><use href="#i-pagebreak"/></svg><span>Page break</span><kbd class="nm-kbd" aria-hidden="true">⌘↵</kbd></button>
<div class="nm-sep" role="separator"></div>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-omega"/></svg>Special character</button>
</div>
</div></div>
</div></div>
<div class="full"><span class="lbl">Three levels, opening to the left</span>
<div class="stage tall" style="--stage-h:400px">
<div class="nm-wrap nm-row"><svg aria-hidden="true"><use href="#i-file"/></svg><span><b>Brand guidelines.pdf</b><small>4.8 MB · Edited by Ines Duarte on 18 Sep</small></span><div class="nm">
<button class="nm-btn nm-dots" id="nm-b-btn" type="button" aria-label="Actions for Brand guidelines.pdf" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-b"><svg aria-hidden="true"><use href="#i-dots"/></svg></button>
<div class="nm-menu start end" role="menu" id="nm-b" aria-label="Actions for Brand guidelines.pdf" hidden>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-eye"/></svg>Open</button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1" aria-keyshortcuts="F2"><svg aria-hidden="true"><use href="#i-pencil"/></svg><span>Rename</span><kbd class="nm-kbd" aria-hidden="true">F2</kbd></button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-copy"/></svg>Duplicate</button>
<div class="nm-sub" role="none">
<button id="nm-b-move" type="button" class="nm-item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-b-move-menu"><svg aria-hidden="true"><use href="#i-move"/></svg><span>Move to</span><svg class="nm-subicon" aria-hidden="true"><use href="#i-chev-left"/></svg></button>
<div class="nm-menu" role="menu" id="nm-b-move-menu" aria-label="Move to" hidden>
<div class="nm-sub" role="none">
<button type="button" class="nm-item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-b-mkt"><svg aria-hidden="true"><use href="#i-folder"/></svg><span>Marketing</span><svg class="nm-subicon" aria-hidden="true"><use href="#i-chev-left"/></svg></button>
<div class="nm-menu" role="menu" id="nm-b-mkt" aria-label="Marketing" hidden>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Campaigns</button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Social media</button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Events</button>
</div>
</div>
<div class="nm-sub" role="none">
<button id="nm-b-design" type="button" class="nm-item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-b-design-menu"><svg aria-hidden="true"><use href="#i-folder"/></svg><span>Design</span><svg class="nm-subicon" aria-hidden="true"><use href="#i-chev-left"/></svg></button>
<div class="nm-menu" role="menu" id="nm-b-design-menu" aria-label="Design" hidden>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Brand 2026</button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Website refresh</button>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Icon library</button>
</div>
</div>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Engineering</button>
<div class="nm-sep" role="separator"></div>
<button type="button" class="nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-plus"/></svg>New folder…</button>
</div>
</div>
<div class="nm-sep" role="separator"></div>
<button type="button" class="nm-item danger" role="menuitem" tabindex="-1" aria-keyshortcuts="Delete"><svg aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span><kbd class="nm-kbd" aria-hidden="true">⌫</kbd></button>
</div>
</div></div>
</div></div>
CSS
/* 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); });
  });
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS, Bootstrap 5.3.8 JS bundle

HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <symbol id="i-chev-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol>
  <symbol id="i-chev-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></symbol>
  <symbol id="i-chev-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m15 6-6 6 6 6"/></symbol>
  <symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z"/><path d="m13.5 6.5 4 4"/></symbol>
  <symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="8.5" y="8.5" width="12" height="12" rx="2"/><path d="M15.5 8.5V5a1.5 1.5 0 0 0-1.5-1.5H5A1.5 1.5 0 0 0 3.5 5v9A1.5 1.5 0 0 0 5 15.5h3.5"/></symbol>
  <symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 7a1.5 1.5 0 0 1 1.5-1.5h4.3l2 2.5H19a1.5 1.5 0 0 1 1.5 1.5v8.5A1.5 1.5 0 0 1 19 19.5H5A1.5 1.5 0 0 1 3.5 18z"/></symbol>
  <symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M10 11v6M14 11v6M5.5 7l1 12a1.5 1.5 0 0 0 1.5 1.5h8a1.5 1.5 0 0 0 1.5-1.5l1-12M9 7V4.5h6V7"/></symbol>
  <symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 14a4 4 0 0 0 5.7 0l3.1-3.1a4 4 0 0 0-5.7-5.7L11.5 6.8"/><path d="M14 10a4 4 0 0 0-5.7 0l-3.1 3.1a4 4 0 0 0 5.7 5.7l1.6-1.6"/></symbol>
  <symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V5M7 9.5l5-5 5 5M5 19.5h14"/></symbol>
  <symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v12a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1z"/><path d="M13.5 3.5v4h4"/></symbol>
  <symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol>
  <symbol id="i-dots" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="5.5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="18.5" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
  <symbol id="i-image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><circle cx="9" cy="10" r="1.8"/><path d="m20.5 16-5-5-9 8.5"/></symbol>
  <symbol id="i-table" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M3.5 9.5h17M3.5 14.5h17M9.5 9.5v10"/></symbol>
  <symbol id="i-chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v16h16"/><path d="M8.5 16v-4M12.5 16V8M16.5 16v-6"/></symbol>
  <symbol id="i-line" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v16h16"/><path d="m7.5 14.5 3.5-4 3 2.5 4.5-6"/></symbol>
  <symbol id="i-pie" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5a8.5 8.5 0 1 0 8.5 8.5H12z"/><path d="M15 3.9A8.5 8.5 0 0 1 20.1 9H15z"/></symbol>
  <symbol id="i-minus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16"/></symbol>
  <symbol id="i-pagebreak" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5v5h12v-5M6 20.5v-5h12v5M3.5 12h2M9 12h2M13 12h2M18.5 12h2"/></symbol>
  <symbol id="i-omega" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19.5h4.5v-2.8A6.5 6.5 0 1 1 14.5 16.7v2.8H19"/></symbol>
  <symbol id="i-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m12 4 2.5 5 5.5.8-4 3.9.9 5.5L12 16.6l-4.9 2.6.9-5.5-4-3.9L9.5 9z"/></symbol>
  <symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3"/></symbol>
  <symbol id="i-move" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h9M9.5 8.5 13 12l-3.5 3.5"/><path d="M13 5.5h5.5a1.5 1.5 0 0 1 1.5 1.5v10a1.5 1.5 0 0 1-1.5 1.5H13"/></symbol>
</svg>
<div class="full"><span class="lbl">Submenus on hover and with arrow keys</span>
<div class="stage tall" style="--stage-h:430px">
<div class="nm-wrap">
<div class="dropdown nm">
<button class="btn nm-btn" id="nm-a-btn" type="button" data-bs-toggle="dropdown" data-bs-offset="0,6" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-a"><svg aria-hidden="true"><use href="#i-plus"/></svg>Insert<svg class="nm-chev" aria-hidden="true"><use href="#i-chev-down"/></svg></button>
<div class="dropdown-menu nm-menu" role="menu" id="nm-a" aria-labelledby="nm-a-btn">
<div class="nm-sub" role="none">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-a-img"><svg aria-hidden="true"><use href="#i-image"/></svg><span>Image</span><svg class="nm-subicon" aria-hidden="true"><use href="#i-chev-right"/></svg></button>
<div class="dropdown-menu nm-menu" role="menu" id="nm-a-img" aria-label="Image">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-upload"/></svg>Upload from computer</button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-link"/></svg>By URL</button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-star"/></svg>Stock photos</button>
</div>
</div>
<div class="nm-sub" role="none">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-a-tbl"><svg aria-hidden="true"><use href="#i-table"/></svg><span>Table</span><svg class="nm-subicon" aria-hidden="true"><use href="#i-chev-right"/></svg></button>
<div class="dropdown-menu nm-menu" role="menu" id="nm-a-tbl" aria-label="Table">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-table"/></svg>Blank table</button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-copy"/></svg>From a template…</button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-upload"/></svg>Import CSV</button>
</div>
</div>
<div class="nm-sub" role="none">
<button id="nm-a-chart" type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-a-chart-menu"><svg aria-hidden="true"><use href="#i-chart"/></svg><span>Chart</span><svg class="nm-subicon" aria-hidden="true"><use href="#i-chev-right"/></svg></button>
<div class="dropdown-menu nm-menu" role="menu" id="nm-a-chart-menu" aria-label="Chart">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-chart"/></svg>Bar chart</button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-line"/></svg>Line chart</button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-pie"/></svg>Pie chart</button>
<hr class="dropdown-divider">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-table"/></svg>From a spreadsheet…</button>
</div>
</div>
<hr class="dropdown-divider">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-minus"/></svg>Divider</button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1" aria-keyshortcuts="Meta+Enter"><svg aria-hidden="true"><use href="#i-pagebreak"/></svg><span>Page break</span><kbd class="nm-kbd" aria-hidden="true">⌘↵</kbd></button>
<hr class="dropdown-divider">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-omega"/></svg>Special character</button>
</div>
</div></div>
</div></div>
<div class="full"><span class="lbl">Three levels, opening to the left</span>
<div class="stage tall" style="--stage-h:400px">
<div class="nm-wrap nm-row"><svg aria-hidden="true"><use href="#i-file"/></svg><span><b>Brand guidelines.pdf</b><small>4.8 MB · Edited by Ines Duarte on 18 Sep</small></span><div class="dropdown nm">
<button class="btn nm-btn nm-dots" id="nm-b-btn" type="button" data-bs-toggle="dropdown" data-bs-offset="0,6" aria-label="Actions for Brand guidelines.pdf" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-b"><svg aria-hidden="true"><use href="#i-dots"/></svg></button>
<div class="dropdown-menu nm-menu start dropdown-menu-end" role="menu" id="nm-b" aria-label="Actions for Brand guidelines.pdf">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-eye"/></svg>Open</button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1" aria-keyshortcuts="F2"><svg aria-hidden="true"><use href="#i-pencil"/></svg><span>Rename</span><kbd class="nm-kbd" aria-hidden="true">F2</kbd></button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-copy"/></svg>Duplicate</button>
<div class="nm-sub" role="none">
<button id="nm-b-move" type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-b-move-menu"><svg aria-hidden="true"><use href="#i-move"/></svg><span>Move to</span><svg class="nm-subicon" aria-hidden="true"><use href="#i-chev-left"/></svg></button>
<div class="dropdown-menu nm-menu" role="menu" id="nm-b-move-menu" aria-label="Move to">
<div class="nm-sub" role="none">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-b-mkt"><svg aria-hidden="true"><use href="#i-folder"/></svg><span>Marketing</span><svg class="nm-subicon" aria-hidden="true"><use href="#i-chev-left"/></svg></button>
<div class="dropdown-menu nm-menu" role="menu" id="nm-b-mkt" aria-label="Marketing">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Campaigns</button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Social media</button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Events</button>
</div>
</div>
<div class="nm-sub" role="none">
<button id="nm-b-design" type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-b-design-menu"><svg aria-hidden="true"><use href="#i-folder"/></svg><span>Design</span><svg class="nm-subicon" aria-hidden="true"><use href="#i-chev-left"/></svg></button>
<div class="dropdown-menu nm-menu" role="menu" id="nm-b-design-menu" aria-label="Design">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Brand 2026</button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Website refresh</button>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Icon library</button>
</div>
</div>
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-folder"/></svg>Engineering</button>
<hr class="dropdown-divider">
<button type="button" class="dropdown-item nm-item" role="menuitem" tabindex="-1"><svg aria-hidden="true"><use href="#i-plus"/></svg>New folder…</button>
</div>
</div>
<hr class="dropdown-divider">
<button type="button" class="dropdown-item nm-item danger" role="menuitem" tabindex="-1" aria-keyshortcuts="Delete"><svg aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span><kbd class="nm-kbd" aria-hidden="true">⌫</kbd></button>
</div>
</div></div>
</div></div>
CSS
/* Bootstrap .dropdown for the first level; submenus are .dropdown-menu elements opened by the small script below. */
.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-btn{--bs-btn-padding-x:12px;--bs-btn-padding-y:0;--bs-btn-font-size:.875rem;--bs-btn-font-weight:600;--bs-btn-color:var(--nm-ink);--bs-btn-bg:#fff;--bs-btn-border-color:#d9d0c7;
  --bs-btn-border-radius:10px;--bs-btn-hover-color:var(--nm-ink);--bs-btn-hover-bg:var(--nm-hover);--bs-btn-hover-border-color:#d9d0c7;--bs-btn-active-color:var(--nm-ink);
  --bs-btn-active-bg:var(--nm-hover);--bs-btn-active-border-color:#d9d0c7;display:inline-flex;align-items:center;gap:8px;height:40px;box-shadow:0 1px 2px rgba(40,24,10,.06)}
.nm-btn svg{width:16px;height:16px;flex:none;color:var(--nm-muted);transition:rotate .2s}
.nm-btn.show .nm-chev{rotate:180deg}
.nm-btn:focus-visible{outline:2px solid var(--nm-accent);outline-offset:2px;box-shadow:none}
.nm-menu{--bs-dropdown-min-width:216px;--bs-dropdown-padding-x:6px;--bs-dropdown-padding-y:6px;--bs-dropdown-font-size:.875rem;--bs-dropdown-color:var(--nm-ink);
  --bs-dropdown-border-color:var(--nm-line);--bs-dropdown-border-radius:12px;--bs-dropdown-divider-bg:var(--nm-line);--bs-dropdown-divider-margin-y:6px;
  --bs-dropdown-link-color:var(--nm-ink);--bs-dropdown-link-hover-color:var(--nm-ink);--bs-dropdown-link-hover-bg:var(--nm-hover);--bs-dropdown-link-active-color:var(--nm-ink);
  --bs-dropdown-link-active-bg:var(--nm-hover);--bs-dropdown-item-padding-x:10px;--bs-dropdown-item-padding-y:8px;--bs-dropdown-item-border-radius:8px;
  box-shadow:0 1px 2px rgba(40,24,10,.05),0 16px 36px -12px rgba(40,24,10,.26)}
.nm-menu.show{animation:nm-in .14s ease-out}
@keyframes nm-in{from{opacity:0;scale:.97}}
.nm-menu .dropdown-divider{margin-inline:-6px;opacity:1}
.nm-item{display:flex;align-items:center;gap:10px;font-weight:500;line-height:1.4}
.nm-item>svg{width:16px;height:16px;flex:none;color:var(--nm-muted)}
.nm-item:focus{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);color:var(--nm-danger)}
.nm-kbd{margin-left:auto;padding:0 0 0 18px;background:none;color:var(--nm-muted);font:500 .75rem ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}

.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.show{animation:none}.nm-btn svg{transition:none}}
JavaScript
// 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());
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <symbol id="i-chev-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol>
  <symbol id="i-chev-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></symbol>
  <symbol id="i-chev-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m15 6-6 6 6 6"/></symbol>
  <symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z"/><path d="m13.5 6.5 4 4"/></symbol>
  <symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="8.5" y="8.5" width="12" height="12" rx="2"/><path d="M15.5 8.5V5a1.5 1.5 0 0 0-1.5-1.5H5A1.5 1.5 0 0 0 3.5 5v9A1.5 1.5 0 0 0 5 15.5h3.5"/></symbol>
  <symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 7a1.5 1.5 0 0 1 1.5-1.5h4.3l2 2.5H19a1.5 1.5 0 0 1 1.5 1.5v8.5A1.5 1.5 0 0 1 19 19.5H5A1.5 1.5 0 0 1 3.5 18z"/></symbol>
  <symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M10 11v6M14 11v6M5.5 7l1 12a1.5 1.5 0 0 0 1.5 1.5h8a1.5 1.5 0 0 0 1.5-1.5l1-12M9 7V4.5h6V7"/></symbol>
  <symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 14a4 4 0 0 0 5.7 0l3.1-3.1a4 4 0 0 0-5.7-5.7L11.5 6.8"/><path d="M14 10a4 4 0 0 0-5.7 0l-3.1 3.1a4 4 0 0 0 5.7 5.7l1.6-1.6"/></symbol>
  <symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V5M7 9.5l5-5 5 5M5 19.5h14"/></symbol>
  <symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v12a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1z"/><path d="M13.5 3.5v4h4"/></symbol>
  <symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol>
  <symbol id="i-dots" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="5.5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="18.5" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
  <symbol id="i-image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><circle cx="9" cy="10" r="1.8"/><path d="m20.5 16-5-5-9 8.5"/></symbol>
  <symbol id="i-table" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M3.5 9.5h17M3.5 14.5h17M9.5 9.5v10"/></symbol>
  <symbol id="i-chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v16h16"/><path d="M8.5 16v-4M12.5 16V8M16.5 16v-6"/></symbol>
  <symbol id="i-line" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v16h16"/><path d="m7.5 14.5 3.5-4 3 2.5 4.5-6"/></symbol>
  <symbol id="i-pie" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5a8.5 8.5 0 1 0 8.5 8.5H12z"/><path d="M15 3.9A8.5 8.5 0 0 1 20.1 9H15z"/></symbol>
  <symbol id="i-minus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16"/></symbol>
  <symbol id="i-pagebreak" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5v5h12v-5M6 20.5v-5h12v5M3.5 12h2M9 12h2M13 12h2M18.5 12h2"/></symbol>
  <symbol id="i-omega" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19.5h4.5v-2.8A6.5 6.5 0 1 1 14.5 16.7v2.8H19"/></symbol>
  <symbol id="i-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m12 4 2.5 5 5.5.8-4 3.9.9 5.5L12 16.6l-4.9 2.6.9-5.5-4-3.9L9.5 9z"/></symbol>
  <symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3"/></symbol>
  <symbol id="i-move" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h9M9.5 8.5 13 12l-3.5 3.5"/><path d="M13 5.5h5.5a1.5 1.5 0 0 1 1.5 1.5v10a1.5 1.5 0 0 1-1.5 1.5H13"/></symbol>
</svg>
<div class="col-span-full"><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#5b6474] uppercase">Submenus on hover and with arrow keys</span>
<div class="relative min-h-[430px] rounded-[18px] border border-[#dfe3eb] bg-[#f8f9fc] bg-[radial-gradient(#d5dae4_1px,transparent_1px)] bg-size-[18px_18px] p-7 max-[600px]:p-5 max-[600px]:min-h-[620px]">
<div class="relative inline-block text-[.875rem]">
<button class="group inline-flex h-10 cursor-pointer items-center gap-2 rounded-[10px] border border-[#d9d0c7] bg-white px-3 text-[.875rem] font-semibold text-[#211a15] shadow-[0_1px_2px_rgba(40,24,10,.06)] transition-colors duration-150 hover:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none" id="nm-a-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-a"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-plus"/></svg>Insert<svg class="size-4 flex-none text-[#645a52] transition-[rotate] duration-200 group-aria-expanded:rotate-180 motion-reduce:transition-none" aria-hidden="true"><use href="#i-chev-down"/></svg></button>
<div class="absolute top-[calc(100%+6px)] left-0 z-20 min-w-[216px] rounded-xl border border-[#ebe4dd] bg-white p-1.5 shadow-[0_1px_2px_rgba(40,24,10,.05),0_16px_36px_-12px_rgba(40,24,10,.26)] animate-menu-in motion-reduce:animate-none" role="menu" id="nm-a" aria-labelledby="nm-a-btn" hidden>
<div class="relative" role="none">
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-a-img"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-image"/></svg><span>Image</span><svg class="ml-auto size-3.5 flex-none text-[#645a52] max-[600px]:group-aria-expanded:rotate-90" aria-hidden="true"><use href="#i-chev-right"/></svg></button>
<div class="z-20 min-w-[216px] rounded-xl border border-[#ebe4dd] bg-white p-1.5 shadow-[0_1px_2px_rgba(40,24,10,.05),0_16px_36px_-12px_rgba(40,24,10,.26)] animate-menu-in motion-reduce:animate-none absolute top-[-7px] left-[calc(100%+2px)] max-[600px]:static max-[600px]:m-[2px_0_4px_16px] max-[600px]:min-w-0 max-[600px]:rounded-none max-[600px]:border-0 max-[600px]:border-l-2 max-[600px]:border-[#ebe4dd] max-[600px]:py-0.5 max-[600px]:pr-0 max-[600px]:pl-2 max-[600px]:shadow-none max-[600px]:animate-none" role="menu" id="nm-a-img" aria-label="Image" hidden>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-upload"/></svg>Upload from computer</button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-link"/></svg>By URL</button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-star"/></svg>Stock photos</button>
</div>
</div>
<div class="relative" role="none">
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-a-tbl"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-table"/></svg><span>Table</span><svg class="ml-auto size-3.5 flex-none text-[#645a52] max-[600px]:group-aria-expanded:rotate-90" aria-hidden="true"><use href="#i-chev-right"/></svg></button>
<div class="z-20 min-w-[216px] rounded-xl border border-[#ebe4dd] bg-white p-1.5 shadow-[0_1px_2px_rgba(40,24,10,.05),0_16px_36px_-12px_rgba(40,24,10,.26)] animate-menu-in motion-reduce:animate-none absolute top-[-7px] left-[calc(100%+2px)] max-[600px]:static max-[600px]:m-[2px_0_4px_16px] max-[600px]:min-w-0 max-[600px]:rounded-none max-[600px]:border-0 max-[600px]:border-l-2 max-[600px]:border-[#ebe4dd] max-[600px]:py-0.5 max-[600px]:pr-0 max-[600px]:pl-2 max-[600px]:shadow-none max-[600px]:animate-none" role="menu" id="nm-a-tbl" aria-label="Table" hidden>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-table"/></svg>Blank table</button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-copy"/></svg>From a template…</button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-upload"/></svg>Import CSV</button>
</div>
</div>
<div class="relative" role="none">
<button id="nm-a-chart" type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-a-chart-menu"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-chart"/></svg><span>Chart</span><svg class="ml-auto size-3.5 flex-none text-[#645a52] max-[600px]:group-aria-expanded:rotate-90" aria-hidden="true"><use href="#i-chev-right"/></svg></button>
<div class="z-20 min-w-[216px] rounded-xl border border-[#ebe4dd] bg-white p-1.5 shadow-[0_1px_2px_rgba(40,24,10,.05),0_16px_36px_-12px_rgba(40,24,10,.26)] animate-menu-in motion-reduce:animate-none absolute top-[-7px] left-[calc(100%+2px)] max-[600px]:static max-[600px]:m-[2px_0_4px_16px] max-[600px]:min-w-0 max-[600px]:rounded-none max-[600px]:border-0 max-[600px]:border-l-2 max-[600px]:border-[#ebe4dd] max-[600px]:py-0.5 max-[600px]:pr-0 max-[600px]:pl-2 max-[600px]:shadow-none max-[600px]:animate-none" role="menu" id="nm-a-chart-menu" aria-label="Chart" hidden>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-chart"/></svg>Bar chart</button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-line"/></svg>Line chart</button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-pie"/></svg>Pie chart</button>
<div class="-mx-1.5 my-1.5 h-px bg-[#ebe4dd]" role="separator"></div>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-table"/></svg>From a spreadsheet…</button>
</div>
</div>
<div class="-mx-1.5 my-1.5 h-px bg-[#ebe4dd]" role="separator"></div>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-minus"/></svg>Divider</button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1" aria-keyshortcuts="Meta+Enter"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-pagebreak"/></svg><span>Page break</span><kbd class="ml-auto pl-[18px] font-mono text-[.75rem] font-medium text-[#645a52]" aria-hidden="true">⌘↵</kbd></button>
<div class="-mx-1.5 my-1.5 h-px bg-[#ebe4dd]" role="separator"></div>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-omega"/></svg>Special character</button>
</div>
</div>
</div></div>
<div class="col-span-full"><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#5b6474] uppercase">Three levels, opening to the left</span>
<div class="relative min-h-[400px] rounded-[18px] border border-[#dfe3eb] bg-[#f8f9fc] bg-[radial-gradient(#d5dae4_1px,transparent_1px)] bg-size-[18px_18px] p-7 max-[600px]:p-5 max-[600px]:min-h-[620px]">
<div class="flex items-center gap-3 rounded-xl border border-[#ebe4dd] bg-white py-2.5 pr-2.5 pl-3.5 text-[.875rem] shadow-[0_1px_2px_rgba(40,24,10,.05)]"><svg class="size-[22px] flex-none text-[#c2410c]" aria-hidden="true"><use href="#i-file"/></svg><span><b class="block leading-[1.3] font-semibold text-[#211a15]">Brand guidelines.pdf</b><small class="block text-[.8rem] text-[#645a52] max-[600px]:hidden">4.8 MB · Edited by Ines Duarte on 18 Sep</small></span><div class="relative ml-auto inline-block">
<button class="group inline-flex h-10 cursor-pointer items-center rounded-[10px] border border-[#d9d0c7] bg-white w-9 h-9 justify-center p-0 text-[.875rem] font-semibold text-[#211a15] shadow-[0_1px_2px_rgba(40,24,10,.06)] transition-colors duration-150 hover:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none" id="nm-b-btn" type="button" aria-label="Actions for Brand guidelines.pdf" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-b"><svg class="size-4 text-[#645a52]" aria-hidden="true"><use href="#i-dots"/></svg></button>
<div class="absolute top-[calc(100%+6px)] left-0 z-20 min-w-[216px] rounded-xl border border-[#ebe4dd] bg-white p-1.5 shadow-[0_1px_2px_rgba(40,24,10,.05),0_16px_36px_-12px_rgba(40,24,10,.26)] animate-menu-in motion-reduce:animate-none right-0 left-auto" role="menu" id="nm-b" aria-label="Actions for Brand guidelines.pdf" hidden>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-eye"/></svg>Open</button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1" aria-keyshortcuts="F2"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-pencil"/></svg><span>Rename</span><kbd class="ml-auto pl-[18px] font-mono text-[.75rem] font-medium text-[#645a52]" aria-hidden="true">F2</kbd></button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-copy"/></svg>Duplicate</button>
<div class="relative" role="none">
<button id="nm-b-move" type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-b-move-menu"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-move"/></svg><span>Move to</span><svg class="ml-auto size-3.5 flex-none text-[#645a52] max-[600px]:group-aria-expanded:-rotate-90" aria-hidden="true"><use href="#i-chev-left"/></svg></button>
<div class="z-20 min-w-[216px] rounded-xl border border-[#ebe4dd] bg-white p-1.5 shadow-[0_1px_2px_rgba(40,24,10,.05),0_16px_36px_-12px_rgba(40,24,10,.26)] animate-menu-in motion-reduce:animate-none absolute top-[-7px] right-[calc(100%+2px)] max-[600px]:static max-[600px]:m-[2px_0_4px_16px] max-[600px]:min-w-0 max-[600px]:rounded-none max-[600px]:border-0 max-[600px]:border-l-2 max-[600px]:border-[#ebe4dd] max-[600px]:py-0.5 max-[600px]:pr-0 max-[600px]:pl-2 max-[600px]:shadow-none max-[600px]:animate-none" role="menu" id="nm-b-move-menu" aria-label="Move to" hidden>
<div class="relative" role="none">
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-b-mkt"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-folder"/></svg><span>Marketing</span><svg class="ml-auto size-3.5 flex-none text-[#645a52] max-[600px]:group-aria-expanded:-rotate-90" aria-hidden="true"><use href="#i-chev-left"/></svg></button>
<div class="z-20 min-w-[216px] rounded-xl border border-[#ebe4dd] bg-white p-1.5 shadow-[0_1px_2px_rgba(40,24,10,.05),0_16px_36px_-12px_rgba(40,24,10,.26)] animate-menu-in motion-reduce:animate-none absolute top-[-7px] right-[calc(100%+2px)] max-[600px]:static max-[600px]:m-[2px_0_4px_16px] max-[600px]:min-w-0 max-[600px]:rounded-none max-[600px]:border-0 max-[600px]:border-l-2 max-[600px]:border-[#ebe4dd] max-[600px]:py-0.5 max-[600px]:pr-0 max-[600px]:pl-2 max-[600px]:shadow-none max-[600px]:animate-none" role="menu" id="nm-b-mkt" aria-label="Marketing" hidden>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-folder"/></svg>Campaigns</button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-folder"/></svg>Social media</button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-folder"/></svg>Events</button>
</div>
</div>
<div class="relative" role="none">
<button id="nm-b-design" type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="nm-b-design-menu"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-folder"/></svg><span>Design</span><svg class="ml-auto size-3.5 flex-none text-[#645a52] max-[600px]:group-aria-expanded:-rotate-90" aria-hidden="true"><use href="#i-chev-left"/></svg></button>
<div class="z-20 min-w-[216px] rounded-xl border border-[#ebe4dd] bg-white p-1.5 shadow-[0_1px_2px_rgba(40,24,10,.05),0_16px_36px_-12px_rgba(40,24,10,.26)] animate-menu-in motion-reduce:animate-none absolute top-[-7px] right-[calc(100%+2px)] max-[600px]:static max-[600px]:m-[2px_0_4px_16px] max-[600px]:min-w-0 max-[600px]:rounded-none max-[600px]:border-0 max-[600px]:border-l-2 max-[600px]:border-[#ebe4dd] max-[600px]:py-0.5 max-[600px]:pr-0 max-[600px]:pl-2 max-[600px]:shadow-none max-[600px]:animate-none" role="menu" id="nm-b-design-menu" aria-label="Design" hidden>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-folder"/></svg>Brand 2026</button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-folder"/></svg>Website refresh</button>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-folder"/></svg>Icon library</button>
</div>
</div>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-folder"/></svg>Engineering</button>
<div class="-mx-1.5 my-1.5 h-px bg-[#ebe4dd]" role="separator"></div>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] group text-[#211a15] hover:bg-[#f8f1ea] focus:bg-[#f8f1ea] aria-expanded:bg-[#f8f1ea]" role="menuitem" tabindex="-1"><svg class="size-4 flex-none text-[#645a52]" aria-hidden="true"><use href="#i-plus"/></svg>New folder…</button>
</div>
</div>
<div class="-mx-1.5 my-1.5 h-px bg-[#ebe4dd]" role="separator"></div>
<button type="button" class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-2.5 py-2 text-left leading-[1.4] font-medium whitespace-nowrap no-underline focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#c2410c] text-[#b42318] hover:bg-[#fef3f2] focus:bg-[#fef3f2]" role="menuitem" tabindex="-1" aria-keyshortcuts="Delete"><svg class="size-4 flex-none" aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span><kbd class="ml-auto pl-[18px] font-mono text-[.75rem] font-medium text-[#645a52]" aria-hidden="true">⌫</kbd></button>
</div>
</div></div>
</div></div>
CSS
@theme {
  --animate-menu-in: menu-in .14s ease-out;
  @keyframes menu-in { from { opacity: 0; scale: .97; } }
}
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); });
  });
})();

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.