Open demo

CSS code

HTML
<nav class="fly" aria-label="Workspace">
  <ul>
    <li><a href="#" aria-current="page"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10"/></svg>Dashboard</a></li>
    <li><button type="button" aria-expanded="false" aria-controls="fly-projects"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>Projects
        <svg class="arw" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>
      <ul class="fly-sub" id="fly-projects">
        <li><a href="#">All projects</a></li>
        <li><a href="#">Active</a></li>
        <li><button type="button" aria-expanded="false" aria-controls="fly-archived">Archived
            <svg class="arw" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>
          <ul class="fly-sub" id="fly-archived">
            <li><a href="#">Last 30 days</a></li>
            <li><a href="#">Last quarter</a></li>
            <li><a href="#">Everything</a></li>
          </ul>
        </li>
        <li class="fly-sep"><a href="#">New project…</a></li>
      </ul>
    </li>
    <li><button type="button" aria-expanded="false" aria-controls="fly-team"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.4"/><path d="M5 20c0-3.4 3-5.6 7-5.6s7 2.2 7 5.6"/></svg>Team
        <svg class="arw" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>
      <ul class="fly-sub" id="fly-team">
        <li><a href="#">Members</a></li>
        <li><a href="#">Invitations</a></li>
        <li><a href="#">Roles and permissions</a></li>
      </ul>
    </li>
    <li class="fly-sep"><a href="#"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.6 1.6 0 00-2.7 1.1V21a2 2 0 11-4 0v-.1A1.6 1.6 0 007 19.4l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.6 1.6 0 00-1.1-2.7H3a2 2 0 110-4h.1A1.6 1.6 0 004.6 7l-.1-.1a2 2 0 112.8-2.8l.1.1a1.6 1.6 0 002.7-1.1V3a2 2 0 114 0v.1a1.6 1.6 0 002.7 1.1l.1-.1a2 2 0 112.8 2.8l-.1.1a1.6 1.6 0 001.1 2.7H21a2 2 0 110 4h-.1a1.6 1.6 0 00-1.5 1.3z"/></svg>Settings</a></li>
  </ul>
</nav>
CSS
/* Nested flyout menu. Every parent item is a <button aria-expanded> followed by its submenu list.
   Pointer hover opens it (with a short close delay), a click or Enter/Space toggles it, Esc closes it and returns focus.
   Under 660px the submenus drop below their item instead of flying out to the side. */
.fly{--fly-ink:#101828;--fly-text:#475467;--fly-hover:#f2f4f8;--fly-accent:#4f46e5;--fly-line:#e7e9f0;font-size:.88rem}
.fly ul{margin:0;padding:7px;list-style:none;background:#fff;border:1px solid var(--fly-line);border-radius:13px;
  box-shadow:0 18px 40px -22px rgba(16,24,40,.3)}
.fly>ul{width:250px}
.fly li{position:relative}
.fly-sep::before{content:"";display:block;height:1px;margin:5px 6px;background:#eef0f5}
.fly a,.fly button{display:flex;align-items:center;gap:11px;width:100%;padding:10px 12px;border:0;border-radius:9px;background:none;
  font:inherit;font-weight:600;line-height:normal;color:var(--fly-text);text-align:left;text-decoration:none;cursor:pointer;
  transition:background-color .14s,color .14s}
.fly a:hover,.fly button:hover,.fly button[aria-expanded="true"]{background:var(--fly-hover);color:var(--fly-ink)}
.fly a[aria-current]{color:var(--fly-ink)}
.fly a:focus-visible,.fly button:focus-visible{outline:2px solid var(--fly-accent);outline-offset:-2px}
.fly svg{display:block;fill:none;flex-shrink:0}
.fly .ic{width:16px;height:16px;stroke:#98a2b3;stroke-width:2}
.fly a:hover .ic,.fly button:hover .ic,.fly [aria-expanded="true"] .ic,.fly [aria-current] .ic{stroke:var(--fly-accent)}
.fly .arw{margin-left:auto;width:13px;height:13px;stroke:#b0b7c5;stroke-width:2.4}
.fly-sub{position:absolute;top:-8px;left:calc(100% + 6px);width:230px;z-index:5;opacity:0;visibility:hidden;transform:translateX(-6px);
  transition:opacity .16s,transform .16s,visibility .16s}
.fly [aria-expanded="true"]+.fly-sub{opacity:1;visibility:visible;transform:none}
@media (max-width:660px){.fly-sub{left:14px;top:100%;width:calc(100% - 28px);transform:translateY(-6px)}}
@media (prefers-reduced-motion:reduce){.fly a,.fly button,.fly-sub{transition:none}}
JavaScript
// Disclosure flyouts: a click, Enter or Space toggles a submenu; pointer hover opens it with a 250 ms close delay
// so a diagonal path into the flyout doesn't snap it shut. Esc closes the submenu you are in and focuses its button.
// Focus or a click landing outside an open branch closes that branch.
document.querySelectorAll('.fly').forEach(nav => {
  const btns = [...nav.querySelectorAll('button[aria-controls]')];
  const isOpen = b => b.getAttribute('aria-expanded') === 'true';
  const set = (b, open) => {
    b.setAttribute('aria-expanded', open);
    if (!open) { delete b.dataset.hover; b.nextElementSibling.querySelectorAll('[aria-expanded="true"]').forEach(c => set(c, false)); }
  };
  const openOnly = b => {
    b.closest('ul').querySelectorAll(':scope > li > [aria-expanded="true"]').forEach(o => o !== b && set(o, false));
    set(b, true);
  };
  btns.forEach(b => {
    const li = b.parentElement;
    let timer;
    b.addEventListener('click', () => {
      if ('hover' in b.dataset) { delete b.dataset.hover; return; } // a mouse click on a flyout that hover already opened keeps it open
      isOpen(b) ? set(b, false) : openOnly(b);
    });
    li.addEventListener('pointerenter', e => {
      if (e.pointerType === 'touch') return;
      clearTimeout(timer);
      if (!isOpen(b)) { openOnly(b); b.dataset.hover = ''; }
    });
    li.addEventListener('pointerleave', e => {
      if (e.pointerType === 'touch') return;
      timer = setTimeout(() => { if (!li.contains(document.activeElement) || document.activeElement === b) set(b, false); }, 250);
    });
  });
  nav.addEventListener('keydown', e => {
    if (e.key !== 'Escape') return;
    const b = isOpen(e.target) && e.target.matches('button[aria-controls]') ? e.target : e.target.closest('.fly-sub')?.previousElementSibling;
    if (!b) return;
    e.preventDefault();
    set(b, false);
    b.focus();
  });
  nav.addEventListener('focusin', e => btns.forEach(b => { if (isOpen(b) && !b.parentElement.contains(e.target)) set(b, false); }));
  document.addEventListener('click', e => { if (!nav.contains(e.target)) btns.forEach(b => set(b, false)); });
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS, Bootstrap 5.3.8 JS bundle

HTML
<nav class="fly" aria-label="Workspace">
  <ul class="nav flex-column">
    <li class="nav-item"><a class="nav-link active" href="#" aria-current="page"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10"/></svg>Dashboard</a></li>
    <li class="nav-item dropend"><button class="nav-link" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" data-bs-display="static" aria-expanded="false"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>Projects
        <svg class="arw" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>
      <ul class="dropdown-menu">
        <li><a class="dropdown-item" href="#">All projects</a></li>
        <li><a class="dropdown-item" href="#">Active</a></li>
        <li class="dropend"><button class="dropdown-item" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" data-bs-display="static" aria-expanded="false">Archived
            <svg class="arw" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>
          <ul class="dropdown-menu">
            <li><a class="dropdown-item" href="#">Last 30 days</a></li>
            <li><a class="dropdown-item" href="#">Last quarter</a></li>
            <li><a class="dropdown-item" href="#">Everything</a></li>
          </ul>
        </li>
        <li><hr class="dropdown-divider mx-1"></li>
        <li><a class="dropdown-item" href="#">New project…</a></li>
      </ul>
    </li>
    <li class="nav-item dropend"><button class="nav-link" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" data-bs-display="static" aria-expanded="false"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.4"/><path d="M5 20c0-3.4 3-5.6 7-5.6s7 2.2 7 5.6"/></svg>Team
        <svg class="arw" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>
      <ul class="dropdown-menu">
        <li><a class="dropdown-item" href="#">Members</a></li>
        <li><a class="dropdown-item" href="#">Invitations</a></li>
        <li><a class="dropdown-item" href="#">Roles and permissions</a></li>
      </ul>
    </li>
    <li class="nav-item"><hr class="sep border-0 opacity-100"></li>
    <li class="nav-item"><a class="nav-link" href="#"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.6 1.6 0 00-2.7 1.1V21a2 2 0 11-4 0v-.1A1.6 1.6 0 007 19.4l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.6 1.6 0 00-1.1-2.7H3a2 2 0 110-4h.1A1.6 1.6 0 004.6 7l-.1-.1a2 2 0 112.8-2.8l.1.1a1.6 1.6 0 002.7-1.1V3a2 2 0 114 0v.1a1.6 1.6 0 002.7 1.1l.1-.1a2 2 0 112.8 2.8l-.1.1a1.6 1.6 0 001.1 2.7H21a2 2 0 110 4h-.1a1.6 1.6 0 00-1.5 1.3z"/></svg>Settings</a></li>
  </ul>
</nav>
CSS
/* A vertical .nav whose parent items are .dropend dropdowns (Bootstrap Dropdown plugin, data-bs-display="static" so
   the CSS below places them). The plugin handles click, Enter/Space, arrow keys, Esc with focus return and outside clicks;
   a short script adds hover opening and closes nested levels with their parent. */
.fly{--fly-ink:#101828;--fly-text:#475467;--fly-hover:#f2f4f8;--fly-accent:#4f46e5;--fly-line:#e7e9f0;font-size:.88rem}
.fly .nav,.fly .dropdown-menu{--bs-dropdown-min-width:0;--bs-dropdown-padding-x:7px;--bs-dropdown-padding-y:7px;--bs-dropdown-font-size:.88rem;
  --bs-dropdown-border-color:var(--fly-line);--bs-dropdown-border-radius:13px;--bs-dropdown-spacer:6px;
  --bs-dropdown-divider-bg:#eef0f5;--bs-dropdown-divider-margin-y:5px;
  --bs-dropdown-link-color:var(--fly-text);--bs-dropdown-link-hover-color:var(--fly-ink);--bs-dropdown-link-hover-bg:var(--fly-hover);
  --bs-dropdown-link-active-color:var(--fly-ink);--bs-dropdown-link-active-bg:var(--fly-hover);
  --bs-dropdown-item-padding-x:12px;--bs-dropdown-item-padding-y:10px;--bs-dropdown-item-border-radius:9px;
  --bs-dropdown-box-shadow:0 18px 40px -22px rgba(16,24,40,.3)}
.fly>.nav{width:250px;padding:7px;background:#fff;border:1px solid var(--fly-line);border-radius:13px;box-shadow:var(--bs-dropdown-box-shadow);
  --bs-nav-link-padding-x:12px;--bs-nav-link-padding-y:10px;--bs-nav-link-font-weight:600;--bs-nav-link-color:var(--fly-text);--bs-nav-link-hover-color:var(--fly-ink)}
.fly .nav-link,.fly .dropdown-item{display:flex;align-items:center;gap:11px;width:100%;font-weight:600;line-height:normal;text-align:left;border-radius:9px;
  transition:background-color .14s,color .14s}
.fly .nav-link:hover,.fly .nav-link.show,.fly .dropdown-item.show{background:var(--fly-hover);color:var(--fly-ink)}
.fly .nav-link.active{color:var(--fly-ink)}
.fly .nav-link:focus-visible,.fly .dropdown-item:focus-visible{outline:2px solid var(--fly-accent);outline-offset:-2px;box-shadow:none}
.fly .dropdown-item:focus:not(:focus-visible){background:var(--bs-dropdown-link-hover-bg)}
.fly svg{display:block;fill:none;flex-shrink:0}
.fly .ic{width:16px;height:16px;stroke:#98a2b3;stroke-width:2}
.fly .nav-link:hover .ic,.fly .nav-link.show .ic,.fly .nav-link.active .ic{stroke:var(--fly-accent)}
.fly .arw{margin-left:auto;width:13px;height:13px;stroke:#b0b7c5;stroke-width:2.4}
.fly .sep{height:1px;margin:5px 6px;background:#eef0f5}
/* keep the menus in the layout so they can fade; visibility still removes closed ones from the tab order */
.fly .dropdown-menu{display:block;width:230px;opacity:0;visibility:hidden;transform:translateX(-6px);transition:opacity .16s,transform .16s,visibility .16s}
.fly .dropdown-menu.show{opacity:1;visibility:visible;transform:none}
/* placed without [data-bs-popper] so a closing menu fades out where it was instead of jumping */
.fly .dropend>.dropdown-menu{top:-8px;right:auto;left:100%;margin:0 0 0 6px}
@media (max-width:660px){
  .fly .dropend>.dropdown-menu{top:100%;left:14px;width:calc(100% - 28px);margin:0;transform:translateY(-6px)}
  .fly .dropend>.dropdown-menu.show{transform:none}}
@media (prefers-reduced-motion:reduce){.fly .nav-link,.fly .dropdown-item,.fly .dropdown-menu{transition:none}}
JavaScript
// Hover opens a flyout (250 ms close delay for diagonal paths); a mouse click on one that hover already opened keeps it open.
// Closing a parent also closes any submenu still open inside it.
document.querySelectorAll('.fly [data-bs-toggle="dropdown"]').forEach(t => {
  const dd = bootstrap.Dropdown.getOrCreateInstance(t), li = t.parentElement;
  let timer;
  li.addEventListener('pointerenter', e => {
    if (e.pointerType === 'touch') return;
    clearTimeout(timer);
    if (!t.classList.contains('show')) { dd.show(); t.dataset.hover = ''; }
  });
  li.addEventListener('pointerleave', e => {
    if (e.pointerType === 'touch') return;
    timer = setTimeout(() => { if (!li.contains(document.activeElement) || document.activeElement === t) dd.hide(); }, 250);
  });
  t.addEventListener('hide.bs.dropdown', e => {
    if (e.target !== t) return;
    delete t.dataset.hover;
    t.nextElementSibling.querySelectorAll('[data-bs-toggle="dropdown"].show').forEach(c => bootstrap.Dropdown.getInstance(c)?.hide());
  });
});
// Bootstrap listens for clicks in the capture phase on document, so this runs on window to get there first.
window.addEventListener('click', e => {
  const t = e.target.closest?.('.fly [data-bs-toggle="dropdown"]');
  if (t && 'hover' in t.dataset) { delete t.dataset.hover; e.stopPropagation(); }
}, true);

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<nav data-fly class="text-[.88rem]" aria-label="Workspace">
  <ul class="m-0 list-none rounded-[13px] border border-[#e7e9f0] bg-white p-[7px] shadow-[0_18px_40px_-22px_rgba(16,24,40,.3)] w-[250px]">
    <li class="relative"><a href="#" aria-current="page" class="group flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none aria-[current=page]:text-[#101828]"><svg class="block size-4 shrink-0 fill-none stroke-[#98a2b3] stroke-2 group-hover:stroke-[#4f46e5] group-aria-[current=page]:stroke-[#4f46e5]" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10"/></svg>Dashboard</a></li>
    <li class="relative"><button type="button" class="peer group flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none cursor-pointer aria-expanded:bg-[#f2f4f8] aria-expanded:text-[#101828]" aria-expanded="false" aria-controls="fly-projects"><svg class="block size-4 shrink-0 fill-none stroke-[#98a2b3] stroke-2 group-hover:stroke-[#4f46e5] group-aria-expanded:stroke-[#4f46e5]" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>Projects
        <svg class="ml-auto block size-[13px] shrink-0 fill-none stroke-[#b0b7c5] stroke-[2.4]" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>
      <ul data-fly-sub class="m-0 list-none rounded-[13px] border border-[#e7e9f0] bg-white p-[7px] shadow-[0_18px_40px_-22px_rgba(16,24,40,.3)] invisible absolute top-[-8px] left-[calc(100%+6px)] z-5 w-[230px] -translate-x-1.5 opacity-0 transition-[opacity,translate,visibility] duration-[160ms] peer-aria-expanded:visible peer-aria-expanded:translate-x-0 peer-aria-expanded:opacity-100 motion-reduce:transition-none max-[660px]:top-full max-[660px]:left-3.5 max-[660px]:w-[calc(100%-28px)] max-[660px]:translate-x-0 max-[660px]:-translate-y-1.5 max-[660px]:peer-aria-expanded:translate-y-0" id="fly-projects">
        <li class="relative"><a href="#" class="flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none">All projects</a></li>
        <li class="relative"><a href="#" class="flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Active</a></li>
        <li class="relative"><button type="button" class="peer group flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none cursor-pointer aria-expanded:bg-[#f2f4f8] aria-expanded:text-[#101828]" aria-expanded="false" aria-controls="fly-archived">Archived
            <svg class="ml-auto block size-[13px] shrink-0 fill-none stroke-[#b0b7c5] stroke-[2.4]" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>
          <ul data-fly-sub class="m-0 list-none rounded-[13px] border border-[#e7e9f0] bg-white p-[7px] shadow-[0_18px_40px_-22px_rgba(16,24,40,.3)] invisible absolute top-[-8px] left-[calc(100%+6px)] z-5 w-[230px] -translate-x-1.5 opacity-0 transition-[opacity,translate,visibility] duration-[160ms] peer-aria-expanded:visible peer-aria-expanded:translate-x-0 peer-aria-expanded:opacity-100 motion-reduce:transition-none max-[660px]:top-full max-[660px]:left-3.5 max-[660px]:w-[calc(100%-28px)] max-[660px]:translate-x-0 max-[660px]:-translate-y-1.5 max-[660px]:peer-aria-expanded:translate-y-0" id="fly-archived">
            <li class="relative"><a href="#" class="flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Last 30 days</a></li>
            <li class="relative"><a href="#" class="flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Last quarter</a></li>
            <li class="relative"><a href="#" class="flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Everything</a></li>
          </ul>
        </li>
        <li class="relative before:mx-1.5 before:my-[5px] before:block before:h-px before:bg-[#eef0f5]"><a href="#" class="flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none">New project…</a></li>
      </ul>
    </li>
    <li class="relative"><button type="button" class="peer group flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none cursor-pointer aria-expanded:bg-[#f2f4f8] aria-expanded:text-[#101828]" aria-expanded="false" aria-controls="fly-team"><svg class="block size-4 shrink-0 fill-none stroke-[#98a2b3] stroke-2 group-hover:stroke-[#4f46e5] group-aria-expanded:stroke-[#4f46e5]" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.4"/><path d="M5 20c0-3.4 3-5.6 7-5.6s7 2.2 7 5.6"/></svg>Team
        <svg class="ml-auto block size-[13px] shrink-0 fill-none stroke-[#b0b7c5] stroke-[2.4]" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></button>
      <ul data-fly-sub class="m-0 list-none rounded-[13px] border border-[#e7e9f0] bg-white p-[7px] shadow-[0_18px_40px_-22px_rgba(16,24,40,.3)] invisible absolute top-[-8px] left-[calc(100%+6px)] z-5 w-[230px] -translate-x-1.5 opacity-0 transition-[opacity,translate,visibility] duration-[160ms] peer-aria-expanded:visible peer-aria-expanded:translate-x-0 peer-aria-expanded:opacity-100 motion-reduce:transition-none max-[660px]:top-full max-[660px]:left-3.5 max-[660px]:w-[calc(100%-28px)] max-[660px]:translate-x-0 max-[660px]:-translate-y-1.5 max-[660px]:peer-aria-expanded:translate-y-0" id="fly-team">
        <li class="relative"><a href="#" class="flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Members</a></li>
        <li class="relative"><a href="#" class="flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Invitations</a></li>
        <li class="relative"><a href="#" class="flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none">Roles and permissions</a></li>
      </ul>
    </li>
    <li class="relative before:mx-1.5 before:my-[5px] before:block before:h-px before:bg-[#eef0f5]"><a href="#" class="group flex w-full items-center gap-[11px] rounded-[9px] px-3 py-2.5 text-left font-semibold text-[#475467] transition-[color,background-color,border-color] duration-[140ms] hover:bg-[#f2f4f8] hover:text-[#101828] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] motion-reduce:transition-none"><svg class="block size-4 shrink-0 fill-none stroke-[#98a2b3] stroke-2 group-hover:stroke-[#4f46e5]" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.6 1.6 0 00-2.7 1.1V21a2 2 0 11-4 0v-.1A1.6 1.6 0 007 19.4l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.6 1.6 0 00-1.1-2.7H3a2 2 0 110-4h.1A1.6 1.6 0 004.6 7l-.1-.1a2 2 0 112.8-2.8l.1.1a1.6 1.6 0 002.7-1.1V3a2 2 0 114 0v.1a1.6 1.6 0 002.7 1.1l.1-.1a2 2 0 112.8 2.8l-.1.1a1.6 1.6 0 001.1 2.7H21a2 2 0 110 4h-.1a1.6 1.6 0 00-1.5 1.3z"/></svg>Settings</a></li>
  </ul>
</nav>
JavaScript
// Disclosure flyouts: a click, Enter or Space toggles a submenu; pointer hover opens it with a 250 ms close delay
// so a diagonal path into the flyout doesn't snap it shut. Esc closes the submenu you are in and focuses its button.
// Focus or a click landing outside an open branch closes that branch.
document.querySelectorAll('[data-fly]').forEach(nav => {
  const btns = [...nav.querySelectorAll('button[aria-controls]')];
  const isOpen = b => b.getAttribute('aria-expanded') === 'true';
  const set = (b, open) => {
    b.setAttribute('aria-expanded', open);
    if (!open) { delete b.dataset.hover; b.nextElementSibling.querySelectorAll('[aria-expanded="true"]').forEach(c => set(c, false)); }
  };
  const openOnly = b => {
    b.closest('ul').querySelectorAll(':scope > li > [aria-expanded="true"]').forEach(o => o !== b && set(o, false));
    set(b, true);
  };
  btns.forEach(b => {
    const li = b.parentElement;
    let timer;
    b.addEventListener('click', () => {
      if ('hover' in b.dataset) { delete b.dataset.hover; return; } // a mouse click on a flyout that hover already opened keeps it open
      isOpen(b) ? set(b, false) : openOnly(b);
    });
    li.addEventListener('pointerenter', e => {
      if (e.pointerType === 'touch') return;
      clearTimeout(timer);
      if (!isOpen(b)) { openOnly(b); b.dataset.hover = ''; }
    });
    li.addEventListener('pointerleave', e => {
      if (e.pointerType === 'touch') return;
      timer = setTimeout(() => { if (!li.contains(document.activeElement) || document.activeElement === b) set(b, false); }, 250);
    });
  });
  nav.addEventListener('keydown', e => {
    if (e.key !== 'Escape') return;
    const b = isOpen(e.target) && e.target.matches('button[aria-controls]') ? e.target : e.target.closest('[data-fly-sub]')?.previousElementSibling;
    if (!b) return;
    e.preventDefault();
    set(b, false);
    b.focus();
  });
  nav.addEventListener('focusin', e => btns.forEach(b => { if (isOpen(b) && !b.parentElement.contains(e.target)) set(b, false); }));
  document.addEventListener('click', e => { if (!nav.contains(e.target)) btns.forEach(b => set(b, false)); });
});

About this set

A vertical app menu whose parent items open flyout submenus to the side, two levels deep. The demo has Dashboard, Projects, Team and Settings with icons; Projects opens All projects, Active, Archived and New project, and Archived opens a third panel of date ranges. Under 660px the flyouts drop below their item instead, so nothing runs off a phone screen. Use it for an admin sidebar or an account menu where a few items hide a short list of sub-pages. Every parent is a button with aria-expanded and aria-controls followed by its submenu list, so this is a disclosure pattern rather than an ARIA menu and Tab walks through it in reading order. A click, a tap, Enter or Space toggles a flyout; a mouse hover opens it, and a 250 ms delay keeps it open while the pointer crosses the gap. Esc closes the flyout you are in and returns focus to its button, and focus or a click landing elsewhere closes the branch. Closed flyouts are visibility:hidden, so their links leave the tab order. The Bootstrap version builds the same menu from nested .dropend dropdowns.

What’s included

  • Two levels of flyout submenus from a vertical menu
  • Hover, click, touch and keyboard all open the same flyouts
  • Esc closes the current flyout and returns focus to its button
  • Flyouts drop below their item under 660px
  • Bootstrap version uses .nav and nested .dropend dropdowns with the Dropdown plugin

Accessibility

The menu is a list inside a nav labelled Workspace, and the current link has aria-current="page". Parent items are buttons with aria-expanded and aria-controls, so screen readers announce whether each submenu is open, and closed submenus are out of the tab order. Esc closes a submenu and returns focus to its button. Every item shows an indigo focus outline, and the fades switch off under prefers-reduced-motion.

Customise it

Change --fly-accent, --fly-hover, --fly-text and --fly-line on .fly, and the widths on .fly>ul (250px) and .fly-sub (230px). In Bootstrap the item colours and padding are the --bs-dropdown-* variables set on .fly; in Tailwind edit the hex values and w-[250px] / w-[230px].