JavaScript Menus: 16 Examples + the Pattern Your Nav Needs

A class toggle is enough to show and hide a panel, but the W3C's ARIA Authoring Practices Guide points most websites to the disclosure pattern, a button that toggles a list of links, instead of the ARIA menu role.

Below are 16 JavaScript menus you can try and copy, followed by a guide to events, ARIA patterns, crawlability and testing.

By Bogdan Sandu · Updated October 28, 2026

Site navigation built on the disclosure pattern

Menus for the main navigation of a website. Every toggle is a button with aria-expanded that shows a list of ordinary links, which is the APG disclosure pattern, plus the hover, scroll and resize behavior real headers need.

Disclosure navigation

  • aria-expanded
  • hidden
  • focusout

Use a mouse, touch, or Tab and Enter. Escape closes any open panel and returns focus to its button.

The APG disclosure navigation pattern: each top-level item that opens a list is a button with aria-expanded and aria-controls, and the list holds plain links. One script closes other panels, closes on Escape with focus returned to the button, and closes when focus or a click lands outside the nav.

View code
.jm-disc {
  container: jm-disc / inline-size;
  width: min(100%, 760px);
  min-height: 330px;
}
.jm-disc__nav {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 18px;
  padding: 10px 12px 10px 20px;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 0 0 1px #e3e6ea, 0 12px 32px rgba(20, 33, 61, .08);
  color: #14213d;
}
.jm-disc__brand {
  margin-right: auto;
  color: #14213d;
  font-family: "Fraunces", serif;
  font-size: 21px;
  font-weight: 700;
  text-decoration: none;
}
.jm-disc__list,
.jm-disc__sub {
  margin: 0;
  padding: 0;
  list-style: none;
}
.jm-disc__list {
  display: flex;
  flex-wrap: wrap;
  gap: 2px;
}
.jm-disc__item {
  position: relative;
}
.jm-disc__btn,
.jm-disc__link {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 12px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: #14213d;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
}
.jm-disc__btn::after {
  content: "";
  width: 6px;
  height: 6px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-2px) rotate(45deg);
}
.jm-disc__btn:hover,
.jm-disc__link:hover {
  background: #f1f4f6;
}
.jm-disc__btn[aria-expanded="true"] {
  background: #e3f1ee;
  color: #0b5e57;
}
.jm-disc__btn[aria-expanded="true"]::after {
  transform: translateY(2px) rotate(-135deg);
}
.jm-disc__btn:focus-visible,
.jm-disc__link:focus-visible,
.jm-disc__brand:focus-visible,
.jm-disc__sub a:focus-visible {
  outline: 3px solid #0f766e;
  outline-offset: 2px;
}
.jm-disc__sub {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  z-index: 3;
  min-width: 250px;
  padding: 8px;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 0 0 1px #e3e6ea, 0 18px 40px rgba(20, 33, 61, .16);
}
.jm-disc__sub[hidden] {
  display: none;
}
.jm-disc__sub a {
  display: block;
  padding: 10px 12px;
  border-radius: 8px;
  color: #14213d;
  font-size: 15px;
  font-weight: 500;
  text-decoration: none;
}
.jm-disc__sub a span {
  display: block;
  color: #56606d;
  font-size: 13px;
  font-weight: 400;
}
.jm-disc__sub a:hover {
  background: #f1f4f6;
}
@container jm-disc (max-width: 520px) {
  .jm-disc__item {
    position: static;
  }
  .jm-disc__sub {
    right: 8px;
    left: 8px;
    min-width: 0;
  }
}
@keyframes jm-disc-in {
  from {
    opacity: 0;
    transform: translateY(-6px);
  }
}
@media (prefers-reduced-motion: no-preference) {
  .jm-disc__sub:not([hidden]) {
    animation: jm-disc-in .18s ease-out;
  }
}

Mega menu with focus management

  • disclosure
  • roving focus
  • ArrowDown into panel

Use a mouse, touch, or Tab and Enter. Escape closes any open panel and returns focus to its button.

A full-width panel is still a disclosure: a button, aria-expanded and a region of grouped links. Focus management is what makes it usable from a keyboard. ArrowDown on the button opens the panel and moves to the first link, arrows move inside it, Escape goes back to the button, and tabbing out closes it.

View code
.jm-mega {
  container: jm-mega / inline-size;
  width: min(100%, 820px);
  min-height: 420px;
}
.jm-mega__bar {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 24px;
  padding: 10px 14px 10px 22px;
  border-radius: 6px;
  background: #1f3b2d;
  color: #f7f3ea;
}
.jm-mega__brand {
  color: #f7f3ea;
  font-size: 19px;
  font-weight: 800;
  letter-spacing: .04em;
  text-transform: uppercase;
  text-decoration: none;
}
.jm-mega ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.jm-mega__top {
  display: flex;
  gap: 4px;
}
.jm-mega__btn,
.jm-mega__link {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 14px;
  border: 0;
  border-radius: 4px;
  background: transparent;
  color: #f7f3ea;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
}
.jm-mega__btn::after {
  content: "";
  width: 6px;
  height: 6px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-2px) rotate(45deg);
}
.jm-mega__btn:hover,
.jm-mega__link:hover {
  background: #2c5240;
}
.jm-mega__btn[aria-expanded="true"] {
  background: #f7f3ea;
  color: #1f3b2d;
}
.jm-mega__btn[aria-expanded="true"]::after {
  transform: translateY(2px) rotate(-135deg);
}
.jm-mega__btn:focus-visible,
.jm-mega__link:focus-visible,
.jm-mega__brand:focus-visible {
  outline: 3px solid #f2b84b;
  outline-offset: 2px;
}
.jm-mega__panel {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  left: 0;
  z-index: 3;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(0, 1.3fr);
  gap: 22px;
  padding: 24px;
  border-radius: 6px;
  background: #f7f3ea;
  box-shadow: 0 24px 50px rgba(18, 36, 27, .22);
  color: #1f3b2d;
}
.jm-mega__panel[hidden] {
  display: none;
}
.jm-mega__head {
  margin: 0 0 8px;
  color: #6b4e1e;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .12em;
  text-transform: uppercase;
}
.jm-mega__col a {
  display: block;
  padding: 7px 0;
  color: #1f3b2d;
  font-size: 15px;
  text-decoration: none;
}
.jm-mega__col a:hover {
  text-decoration: underline;
  text-underline-offset: 3px;
}
.jm-mega__col a:focus-visible,
.jm-mega__feature:focus-visible {
  outline: 3px solid #1f3b2d;
  outline-offset: 2px;
  border-radius: 2px;
}
.jm-mega__feature {
  display: grid;
  gap: 4px;
  color: #1f3b2d;
  text-decoration: none;
}
.jm-mega__feature img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  margin-bottom: 6px;
  border-radius: 4px;
  object-fit: cover;
}
.jm-mega__feature-title {
  font-family: "Fraunces", serif;
  font-size: 18px;
  font-weight: 600;
}
.jm-mega__feature-text {
  color: #4d5a52;
  font-size: 13px;
}
#jm-mega-learn {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
@container jm-mega (max-width: 620px) {
  .jm-mega__panel,
  #jm-mega-learn {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 16px;
    padding: 18px;
  }
  .jm-mega__feature {
    display: none;
  }
}
@keyframes jm-mega-in {
  from {
    opacity: 0;
    transform: translateY(-8px);
  }
}
@media (prefers-reduced-motion: no-preference) {
  .jm-mega__panel:not([hidden]) {
    animation: jm-mega-in .2s ease-out;
  }
}

Multi-level flyout with hover intent

  • setTimeout / clearTimeout
  • matchMedia hover
  • arrow keys

Use a mouse, touch, or Tab and Enter. Escape closes any open panel and returns focus to its button.

Hover opens a level only after the pointer rests for 300 ms, and leaving waits 400 ms before closing, so a diagonal path toward a submenu does not flip panels. One timer per menu is cleared before every new schedule. Click and the keyboard bypass the delay: ArrowRight opens a level, ArrowLeft and Escape step back out.

View code
.jm-fly {
  container: jm-fly / inline-size;
  width: min(100%, 700px);
  min-height: 340px;
  color: #f4f4f5;
}
.jm-fly__nav {
  position: relative;
  width: 214px;
}
.jm-fly__list {
  margin: 0;
  padding: 8px;
  list-style: none;
  border-radius: 12px;
  background: #1c1c21;
  box-shadow: 0 0 0 1px #2e2e36;
}
.jm-fly__btn,
.jm-fly__item > a {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  min-height: 44px;
  padding: 0 12px;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: #f4f4f5;
  font: inherit;
  font-size: 15px;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}
.jm-fly__btn::after {
  content: "";
  width: 6px;
  height: 6px;
  border-top: 2px solid currentColor;
  border-right: 2px solid currentColor;
  transform: rotate(45deg);
}
.jm-fly__btn:hover,
.jm-fly__item > a:hover {
  background: #2a2a31;
}
.jm-fly__btn[aria-expanded="true"] {
  background: #f5b03d;
  color: #18181b;
}
.jm-fly__btn:focus-visible,
.jm-fly__item > a:focus-visible {
  outline: 3px solid #f5b03d;
  outline-offset: -3px;
}
.jm-fly__btn[aria-expanded="true"]:focus-visible {
  outline-color: #fff;
}
.jm-fly__panel {
  position: absolute;
  top: 0;
  left: calc(100% + 6px);
  z-index: 3;
  width: 214px;
}
.jm-fly__panel .jm-fly__list {
  box-shadow: 0 0 0 1px #2e2e36, 0 20px 40px rgba(0, 0, 0, .4);
}
.jm-fly__panel[hidden] {
  display: none;
}
@container jm-fly (max-width: 660px) {
  .jm-fly__nav {
    width: 100%;
  }
  .jm-fly__panel {
    position: static;
    width: auto;
    margin: 4px 0 4px 12px;
  }
  .jm-fly__panel .jm-fly__list {
    padding: 4px 0 4px 8px;
    border-left: 2px solid #f5b03d;
    border-radius: 0;
    background: transparent;
    box-shadow: none;
  }
  .jm-fly__btn::after {
    transform: rotate(135deg);
  }
  .jm-fly__btn[aria-expanded="true"]::after {
    transform: rotate(-45deg);
  }
}
@keyframes jm-fly-in {
  from {
    opacity: 0;
    transform: translateX(-6px);
  }
}
@media (prefers-reduced-motion: no-preference) {
  .jm-fly__panel:not([hidden]) {
    animation: jm-fly-in .16s ease-out;
  }
}

Priority+ nav with a More menu

  • ResizeObserver
  • requestAnimationFrame
  • cached widths

Use a mouse, touch, or Tab and Enter. Escape closes any open panel and returns focus to its button.

Links that do not fit move into a More disclosure instead of wrapping. Item widths are measured once (and again after fonts load), so each resize is plain arithmetic with no layout reads in a loop. The ResizeObserver work runs in requestAnimationFrame, and the More button gets a dot when the current page has moved into it. Drag the slider to watch items move.

View code
.jm-more {
  display: grid;
  gap: 18px;
  justify-items: center;
  width: min(100%, 780px);
  min-height: 330px;
  align-content: start;
}
.jm-more__frame {
  width: var(--jm-more-w, 100%);
  min-width: 0;
}
.jm-more__bar {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 10px 8px 18px;
  border: 1px solid #ead9cc;
  border-radius: 12px;
  background: #fffaf5;
  color: #3b1d10;
}
.jm-more__brand {
  flex: none;
  color: #3b1d10;
  font-family: "Fraunces", serif;
  font-size: 19px;
  font-weight: 700;
  text-decoration: none;
}
.jm-more__list,
.jm-more__menu {
  margin: 0;
  padding: 0;
  list-style: none;
}
.jm-more__list {
  display: flex;
  flex: 1 1 auto;
  gap: 4px;
  min-width: 0;
  overflow: hidden;
}
.jm-more__item {
  flex: none;
}
.jm-more__item a,
.jm-more__btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 40px;
  padding: 0 12px;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: #3b1d10;
  font: inherit;
  font-size: 14.5px;
  font-weight: 500;
  white-space: nowrap;
  text-decoration: none;
  cursor: pointer;
}
.jm-more__item a:hover,
.jm-more__btn:hover {
  background: #f6e8dc;
}
.jm-more__item a[aria-current="page"] {
  background: #9a3412;
  color: #fff;
}
.jm-more__item a:focus-visible,
.jm-more__btn:focus-visible,
.jm-more__brand:focus-visible {
  outline: 3px solid #9a3412;
  outline-offset: 2px;
}
.jm-more__wrap {
  position: relative;
  flex: none;
  margin-left: auto;
}
.jm-more__wrap[hidden] {
  display: none;
}
.jm-more__btn {
  border: 1px solid #ead9cc;
  font-weight: 600;
}
.jm-more__btn::after {
  content: "";
  width: 6px;
  height: 6px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-2px) rotate(45deg);
}
.jm-more__btn--current::before {
  content: "";
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: #9a3412;
}
.jm-more__menu {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  z-index: 3;
  display: grid;
  min-width: 190px;
  padding: 6px;
  border-radius: 10px;
  background: #fff;
  box-shadow: 0 0 0 1px #ead9cc, 0 16px 36px rgba(59, 29, 16, .16);
}
.jm-more__menu[hidden] {
  display: none;
}
.jm-more__menu .jm-more__item a {
  display: flex;
  width: 100%;
  box-sizing: border-box;
}
.jm-more__control {
  display: grid;
  gap: 6px;
  width: min(100%, 280px);
  color: #5b4335;
  font-size: 13px;
  font-weight: 600;
  text-align: center;
}
.jm-more__range {
  width: 100%;
  min-height: 24px;
  accent-color: #9a3412;
}
.jm-more__range:focus-visible {
  outline: 3px solid #9a3412;
  outline-offset: 2px;
}

Header that hides on scroll down

  • passive scroll listener
  • requestAnimationFrame
  • position: sticky
Saltmarsh

Field notes, issue 14

The tide clock on Hollow Point

Twice a day the creek behind the boathouse empties to a ribbon of silver mud, and twice a day it fills again until the cordgrass stands knee deep in water. Living beside it, you stop reading the time on your phone and start reading it on the posts.

The oldest post has a painted scale, faded to pale blue. Ewan, who has kept the boathouse for eleven years, marks the high water on it every spring with a nail and a scrap of red cloth.

Scroll down slowly and the header slides out of the way. Scroll back up even a little and it returns, which is the behavior most news apps settled on because readers scroll up when they want to go somewhere else.

On the far bank, a line of oystercatchers works the edge of the falling tide. They leave in one long string when the water turns, always toward the sandbar, never inland.

By late afternoon the light goes flat and gold, and the marsh looks like a field until a boat moves through it. That is the hour the photographers come, tripods first, wading boots second.

The tide table pinned inside the boathouse door is two years old. Nobody has replaced it, because nobody needs it anymore.

Use a mouse, touch, or Tab and Enter. Escape closes any open panel and returns focus to its button.

position: sticky does the sticking, and the script only decides direction. The scroll listener is passive and hands the work to one requestAnimationFrame per frame, a 6px threshold ignores jitter, and focus entering the header always brings it back. The demo scrolls its own container, so the page around it is not affected.

View code
.jm-hide {
  position: relative;
  height: 100%;
  overflow: hidden;
  background: #f4f1ea;
  color: #1d2b2a;
}
.jm-hide__scroll {
  height: 100%;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.jm-hide__scroll:focus-visible {
  outline: 3px solid #1f6f6a;
  outline-offset: -3px;
}
.jm-hide__bar {
  position: sticky;
  top: 0;
  z-index: 2;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 4px 12px;
  padding: 10px 14px 10px 18px;
  border-bottom: 1px solid #d8d2c4;
  background: #f4f1ea;
}
.jm-hide__bar[data-state="hidden"] {
  transform: translateY(-100%);
}
.jm-hide__brand {
  color: #1d2b2a;
  font-family: "Fraunces", serif;
  font-size: 20px;
  font-weight: 700;
  text-decoration: none;
}
.jm-hide__nav {
  display: flex;
  gap: 2px;
}
.jm-hide__nav a {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  padding: 0 9px;
  border-radius: 8px;
  color: #1d2b2a;
  font-size: 14px;
  font-weight: 600;
  text-decoration: none;
}
.jm-hide__nav a[aria-current="page"] {
  background: #1f6f6a;
  color: #fff;
}
.jm-hide__nav a:focus-visible,
.jm-hide__brand:focus-visible {
  outline: 3px solid #1f6f6a;
  outline-offset: 2px;
}
.jm-hide__article {
  padding: 22px 20px 40px;
  font-size: 15.5px;
  line-height: 1.65;
}
.jm-hide__article p {
  margin: 0 0 16px;
}
.jm-hide__kicker {
  color: #8a4b1f;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
}
.jm-hide__article .jm-hide__title {
  margin-bottom: 18px;
  font-family: "Fraunces", serif;
  font-size: 28px;
  font-weight: 600;
  line-height: 1.15;
}
@media (prefers-reduced-motion: no-preference) {
  .jm-hide__bar {
    transition: transform .25s ease;
  }
}

Scrollspy table of contents

  • IntersectionObserver
  • aria-current
  • scrollTo

Overview

The Ledgerly billing API is organized around REST. Every request is made over HTTPS and returns JSON, including errors.

Amounts are integers in the smallest currency unit, so 1250 means 12.50 in euros.

Authentication

Send your secret key in the Authorization header as a bearer token. Keys starting with test_ never move real money.

Rotate keys from the dashboard. Old keys keep working for 24 hours.

Create a charge

POST to /v2/charges with an amount, a currency and a payment method. Add an idempotency key so a retried request never charges twice.

The response includes a status of pending, succeeded or failed.

Refunds

Refund all or part of a succeeded charge. Partial refunds can repeat until the original amount is used up.

Webhooks

Ledgerly posts events such as charge.succeeded to your endpoint. Verify the signature header before trusting the body.

Failed deliveries retry with backoff for three days.

Errors

Errors use standard HTTP codes. A 402 means the payment method was declined, and the body explains why.

Use a mouse, touch, or Tab and Enter. Escape closes any open panel and returns focus to its button.

An IntersectionObserver rooted on the scroll container watches each section through a band at the top third of the view, so no scroll listener runs at all. The section that most recently entered the band gets aria-current on its link. Clicking a link scrolls the container (smoothly only when motion is allowed) and moves focus to the section.

View code
.jm-spy {
  display: grid;
  grid-template-columns: 190px minmax(0, 1fr);
  gap: 20px;
  width: min(100%, 760px);
  color: #1e1b4b;
}
.jm-spy__toc {
  align-self: start;
  padding: 16px 10px;
  border-radius: 12px;
  background: #fff;
}
.jm-spy__label {
  margin: 0 0 8px 10px;
  color: #4b4f68;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
}
.jm-spy__toc ul {
  display: grid;
  gap: 2px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.jm-spy__toc a {
  display: flex;
  align-items: center;
  min-height: 36px;
  padding: 0 10px;
  border-left: 3px solid transparent;
  border-radius: 0 8px 8px 0;
  color: #3f4462;
  font-size: 14.5px;
  text-decoration: none;
}
.jm-spy__toc a:hover {
  color: #1e1b4b;
  background: #f1f1fb;
}
.jm-spy__toc a[aria-current="true"] {
  border-left-color: #4f46e5;
  background: #eef0ff;
  color: #3730a3;
  font-weight: 700;
}
.jm-spy__toc a:focus-visible,
.jm-spy__body:focus-visible,
.jm-spy__section:focus-visible {
  outline: 3px solid #4f46e5;
  outline-offset: 2px;
}
.jm-spy__body {
  position: relative;
  height: 330px;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 6px 24px 0;
  border-radius: 12px;
  background: #fff;
  font-size: 15px;
  line-height: 1.6;
}
.jm-spy__body::after {
  content: "";
  display: block;
  height: 220px;
}
.jm-spy__section {
  padding: 16px 0 8px;
  border-bottom: 1px solid #ececf4;
}
.jm-spy__section:focus {
  outline: none;
}
.jm-spy__section p {
  margin: 0 0 10px;
  color: #33364d;
}
.jm-spy__section .jm-spy__h {
  margin-bottom: 8px;
  color: #1e1b4b;
  font-family: "Fraunces", serif;
  font-size: 21px;
  font-weight: 600;
}
.jm-spy__section code {
  padding: 1px 5px;
  border-radius: 4px;
  background: #f1f1fb;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
}
@media (max-width: 640px) {
  .jm-spy {
    grid-template-columns: minmax(0, 1fr);
    gap: 12px;
  }
  .jm-spy__toc ul {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
  }
  .jm-spy__toc a {
    min-height: 32px;
    border-left: 0;
    border-radius: 999px;
    background: #f1f1fb;
    font-size: 13.5px;
  }
  .jm-spy__toc a[aria-current="true"] {
    background: #4f46e5;
    color: #fff;
  }
  .jm-spy__body {
    height: 280px;
    padding: 4px 16px 0;
  }
}

Mobile navigation

Three phone-sized menus where the JavaScript does the work CSS cannot: a focus trap with inert, a nested accordion that opens the path to the current page, and a bottom sheet you can drag away.

Hamburger drawer with focus trap and inert

  • inert
  • focus trap
  • aria-modal
Tidewater

Harbour kitchen, open from 5 pm

Fish from the morning boats, cooked over oak

Our menu changes with the catch. Book a table by the window and watch the boats come in.

Book a table
  • Grilled hakeBrown butter, capers, charred leeks
  • Smoked musselsCider, shallots, sourdough

Tap or click the controls inside the phone frame.

While the drawer is open, the header and page behind it get the inert attribute, so they leave the tab order and the accessibility tree. A small Tab handler wraps focus between the first and last control, which also stops focus escaping to the page around the demo. Closing returns focus to the menu button.

View code
.jm-burger {
  position: relative;
  height: 100%;
  overflow: hidden;
  background: #fbf6ee;
  color: #2b2119;
}
.jm-burger__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 14px 12px 20px;
  border-bottom: 1px solid #eadfcd;
}
.jm-burger__brand {
  color: #2b2119;
  font-family: "Fraunces", serif;
  font-size: 22px;
  font-weight: 700;
  text-decoration: none;
}
.jm-burger__open {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  padding: 0 16px;
  border: 1px solid #2b2119;
  border-radius: 999px;
  background: transparent;
  color: #2b2119;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.jm-burger__icon,
.jm-burger__icon::before,
.jm-burger__icon::after {
  display: block;
  width: 16px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
}
.jm-burger__icon {
  position: relative;
}
.jm-burger__icon::before,
.jm-burger__icon::after {
  content: "";
  position: absolute;
  left: 0;
}
.jm-burger__icon::before {
  top: -5px;
}
.jm-burger__icon::after {
  top: 5px;
}
.jm-burger__page {
  padding: 26px 20px;
}
.jm-burger__page p {
  margin: 0 0 14px;
  font-size: 15px;
  line-height: 1.55;
}
.jm-burger__page .jm-burger__kicker {
  margin-bottom: 8px;
  color: #8c3d1c;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
}
.jm-burger__page .jm-burger__title {
  font-family: "Fraunces", serif;
  font-size: 28px;
  font-weight: 600;
  line-height: 1.12;
}
.jm-burger__cta,
.jm-burger__book {
  display: inline-flex;
  align-items: center;
  min-height: 48px;
  padding: 0 22px;
  border-radius: 999px;
  background: #8c3d1c;
  color: #fff;
  font-weight: 700;
  text-decoration: none;
}
.jm-burger__cards {
  display: grid;
  gap: 10px;
  margin: 24px 0 0;
  padding: 0;
  list-style: none;
}
.jm-burger__cards li {
  display: grid;
  gap: 2px;
  padding: 14px 16px;
  border-radius: 14px;
  background: #fff;
  font-size: 14px;
}
.jm-burger__cards span {
  color: #5f5246;
}
.jm-burger__scrim {
  position: absolute;
  inset: 0;
  z-index: 2;
  background: rgba(30, 22, 15, .55);
  opacity: 0;
  visibility: hidden;
  transition: opacity .3s ease, visibility 0s .3s;
}
.jm-burger--open .jm-burger__scrim {
  opacity: 1;
  visibility: visible;
  transition: opacity .3s ease;
}
.jm-burger__drawer {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: 3;
  display: flex;
  flex-direction: column;
  gap: 18px;
  width: min(84%, 320px);
  box-sizing: border-box;
  padding: 16px 22px 26px;
  background: #2b2119;
  color: #fbf6ee;
  transform: translateX(100%);
  visibility: hidden;
}
.jm-burger--open .jm-burger__drawer {
  transform: none;
  visibility: visible;
}
.jm-burger__close {
  position: relative;
  align-self: flex-end;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 1px solid #6d5b4b;
  border-radius: 50%;
  background: transparent;
  color: #fbf6ee;
  cursor: pointer;
}
.jm-burger__close::before,
.jm-burger__close::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 18px;
  height: 2px;
  margin: -1px 0 0 -9px;
  border-radius: 2px;
  background: currentColor;
  transform: rotate(45deg);
}
.jm-burger__close::after {
  transform: rotate(-45deg);
}
.jm-burger__links {
  display: grid;
  margin: 0;
  padding: 0;
  list-style: none;
}
.jm-burger__links a {
  display: flex;
  align-items: center;
  min-height: 52px;
  border-bottom: 1px solid #4a3b2f;
  color: #fbf6ee;
  font-family: "Fraunces", serif;
  font-size: 22px;
  text-decoration: none;
}
.jm-burger__links a[aria-current="page"] {
  color: #f3b98f;
}
.jm-burger__book {
  justify-content: center;
  margin-top: auto;
  background: #fbf6ee;
  color: #2b2119;
}
.jm-burger a:focus-visible,
.jm-burger button:focus-visible {
  outline: 3px solid #d9682b;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .jm-burger__drawer {
    transition: transform .32s cubic-bezier(.2, .8, .2, 1), visibility 0s .32s;
  }
  .jm-burger--open .jm-burger__drawer {
    transition: transform .32s cubic-bezier(.2, .8, .2, 1);
  }
}

Nested accordion navigation

  • link plus toggle
  • aria-current
  • nested disclosure

Tap or click the controls inside the phone frame.

Top-level rows that are also pages get a real link plus a separate toggle button, the APG variant with top-level links. Nested sections repeat the same button and list pair. On load the script opens every ancestor of the aria-current page, so users land with their location already visible.

View code
.jm-acc {
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow: hidden;
  background: #fff;
  color: #13233a;
}
.jm-acc__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 20px;
  background: #13233a;
}
.jm-acc__brand {
  color: #fff;
  font-family: "Fraunces", serif;
  font-size: 20px;
  font-weight: 600;
  text-decoration: none;
}
.jm-acc__tag {
  color: #c9d6ea;
  font-size: 13px;
  font-weight: 600;
}
.jm-acc__nav {
  flex: 1;
  overflow-y: auto;
  padding: 6px 0 20px;
}
.jm-acc__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.jm-acc__list[hidden] {
  display: none;
}
.jm-acc__row {
  border-bottom: 1px solid #e3e8ef;
}
.jm-acc__list--sub .jm-acc__row:last-child {
  border-bottom: 0;
}
.jm-acc__line {
  display: flex;
  align-items: center;
}
.jm-acc__line > a {
  flex: 1;
  display: flex;
  align-items: center;
  min-height: 54px;
  padding: 0 20px;
  color: #13233a;
  font-size: 17px;
  font-weight: 600;
  text-decoration: none;
}
.jm-acc__toggle {
  position: relative;
  flex: none;
  width: 54px;
  height: 54px;
  padding: 0;
  border: 0;
  border-left: 1px solid #e3e8ef;
  background: transparent;
  color: #13233a;
  font: inherit;
  cursor: pointer;
}
.jm-acc__toggle::after {
  content: "";
  position: absolute;
  top: 50%;
  right: 22px;
  width: 8px;
  height: 8px;
  margin-top: -6px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
}
.jm-acc__toggle[aria-expanded="true"]::after {
  margin-top: -2px;
  transform: rotate(-135deg);
}
.jm-acc__toggle[aria-expanded="true"] {
  background: #eef3fa;
}
.jm-acc__toggle--wide {
  display: flex;
  align-items: center;
  width: 100%;
  height: 48px;
  padding: 0 20px 0 32px;
  border-left: 0;
  background: #f6f8fb;
  font-size: 15px;
  font-weight: 600;
  text-align: left;
}
.jm-acc__list--sub {
  background: #f6f8fb;
}
.jm-acc__list--leaf a {
  display: flex;
  align-items: center;
  min-height: 44px;
  padding: 0 20px 0 44px;
  color: #2c3e57;
  font-size: 15px;
  text-decoration: none;
}
.jm-acc__list--sub .jm-acc__list--leaf a {
  padding-left: 52px;
}
.jm-acc__list--leaf a[aria-current="page"] {
  box-shadow: inset 4px 0 0 #c2410c;
  color: #9a3412;
  font-weight: 700;
}
.jm-acc__apply {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 50px;
  margin: 20px 20px 0;
  border-radius: 10px;
  background: #c2410c;
  color: #fff;
  font-weight: 700;
  text-decoration: none;
}
.jm-acc a:focus-visible,
.jm-acc button:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: -3px;
}
@media (prefers-reduced-motion: no-preference) {
  .jm-acc__toggle::after {
    transition: transform .2s ease, margin-top .2s ease;
  }
}

Touch-friendly bottom sheet menu

  • pointer events
  • setPointerCapture
  • inert
Old canal houses with rowboats moored along the water

Canal at first light

May 12, 6:41 am, 24 MP

Tap or click the controls inside the phone frame.

Actions sit within thumb reach in 56px rows. The grip uses pointer events with setPointerCapture, so a drag down of more than 90px dismisses the sheet and a shorter one springs back. A drag is never the only way out: the grip is also a close button, and Escape, the scrim and Cancel all close it and return focus.

View code
.jm-sheet {
  position: relative;
  height: 100%;
  overflow: hidden;
  background: #101114;
  color: #f2f2f4;
}
.jm-sheet__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px 8px 16px;
}
.jm-sheet__back {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  color: #f2f2f4;
  font-weight: 600;
  text-decoration: none;
}
.jm-sheet__back::before {
  content: "";
  width: 9px;
  height: 9px;
  border-bottom: 2px solid currentColor;
  border-left: 2px solid currentColor;
  transform: rotate(45deg);
}
.jm-sheet__open {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #24262c;
  color: #f2f2f4;
  cursor: pointer;
}
.jm-sheet__open span,
.jm-sheet__open span::before,
.jm-sheet__open span::after {
  display: block;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: currentColor;
}
.jm-sheet__open span {
  position: relative;
}
.jm-sheet__open span::before,
.jm-sheet__open span::after {
  content: "";
  position: absolute;
  top: 0;
}
.jm-sheet__open span::before {
  left: -9px;
}
.jm-sheet__open span::after {
  left: 9px;
}
.jm-sheet__photo {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.jm-sheet__info {
  padding: 16px 18px;
}
.jm-sheet__info p {
  margin: 0;
}
.jm-sheet__name {
  font-size: 18px;
  font-weight: 700;
}
.jm-sheet__meta {
  color: #a9abb3;
  font-size: 14px;
}
.jm-sheet__info .jm-sheet__status {
  min-height: 22px;
  margin-top: 12px;
  color: #7fd1a8;
  font-size: 14px;
  font-weight: 600;
}
.jm-sheet__scrim {
  position: absolute;
  inset: 0;
  z-index: 2;
  background: rgba(0, 0, 0, .55);
  opacity: 0;
  visibility: hidden;
  transition: opacity .3s ease, visibility 0s .3s;
}
.jm-sheet--open .jm-sheet__scrim {
  opacity: 1;
  visibility: visible;
  transition: opacity .3s ease;
}
.jm-sheet__panel {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 3;
  padding: 0 14px 16px;
  border-radius: 22px 22px 0 0;
  background: #fff;
  color: #17181c;
  transform: translateY(105%);
  visibility: hidden;
}
.jm-sheet--open .jm-sheet__panel {
  transform: translateY(0);
  visibility: visible;
}
.jm-sheet__grip {
  display: grid;
  place-items: center;
  width: 100%;
  height: 32px;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: grab;
  touch-action: none;
}
.jm-sheet__grip span {
  width: 40px;
  height: 5px;
  border-radius: 3px;
  background: #c4c6cc;
}
.jm-sheet--drag .jm-sheet__grip {
  cursor: grabbing;
}
.jm-sheet__title {
  margin: 0 0 6px;
  color: #5b5e66;
  font-size: 13px;
  font-weight: 600;
  text-align: center;
}
.jm-sheet__list {
  margin: 0 0 10px;
  padding: 0;
  overflow: hidden;
  list-style: none;
  border-radius: 14px;
  background: #f2f3f5;
}
.jm-sheet__list li + li {
  border-top: 1px solid #e1e3e8;
}
.jm-sheet__list button,
.jm-sheet__cancel {
  display: flex;
  align-items: center;
  width: 100%;
  min-height: 56px;
  padding: 0 18px;
  border: 0;
  background: transparent;
  color: #17181c;
  font: inherit;
  font-size: 16px;
  text-align: left;
  cursor: pointer;
}
.jm-sheet__list .jm-sheet__danger {
  color: #c0262d;
}
.jm-sheet__cancel {
  justify-content: center;
  border-radius: 14px;
  background: #f2f3f5;
  font-weight: 700;
}
.jm-sheet__list button:active,
.jm-sheet__cancel:active {
  background: #e4e6ea;
}
.jm-sheet a:focus-visible,
.jm-sheet button:focus-visible {
  outline: 3px solid #3b82f6;
  outline-offset: -3px;
}
@media (prefers-reduced-motion: no-preference) {
  .jm-sheet__panel {
    transition: transform .3s cubic-bezier(.2, .8, .2, 1), visibility 0s .3s;
  }
  .jm-sheet--open .jm-sheet__panel {
    transition: transform .3s cubic-bezier(.2, .8, .2, 1);
  }
  .jm-sheet--drag .jm-sheet__panel {
    transition: none;
  }
}

Application menus with role="menu"

The ARIA menu role belongs here: lists of actions or settings in an app, not links to pages. Each demo implements the full keyboard contract, with arrow keys, Home and End, type-ahead and Escape.

Context menu with a keyboard path

  • contextmenu event
  • Shift+F10
  • clamped position

Project files

Right-click a file, or Tab to one and press Shift+F10 or the Menu key.

The contextmenu event opens the menu at the pointer, clamped so it never leaves the panel. Keyboard users get the same menu from Shift+F10 or the Menu key, placed under the focused file, and Escape sends focus back to that file. iOS Safari does not fire contextmenu on a long press, so a real app also needs a visible actions button for touch.

View code
.jm-ctx {
  position: relative;
  width: min(100%, 600px);
  min-height: 330px;
  padding: 18px;
  box-sizing: border-box;
  border-radius: 16px;
  background: #1f2430;
  color: #e8ebf2;
}
.jm-ctx__label {
  margin: 0 0 12px;
  color: #b9c0cf;
  font-size: 13px;
  font-weight: 600;
}
.jm-ctx__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
  gap: 12px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.jm-ctx__file {
  display: grid;
  gap: 10px;
  width: 100%;
  padding: 12px;
  border: 1px solid #343b4b;
  border-radius: 12px;
  background: #272d3b;
  color: #e8ebf2;
  font: inherit;
  font-size: 13.5px;
  text-align: left;
  overflow-wrap: anywhere;
  cursor: default;
}
.jm-ctx__file:hover {
  border-color: #56607a;
}
.jm-ctx__file--target {
  border-color: #fbbf24;
  background: #313848;
}
.jm-ctx__file:focus-visible {
  outline: 3px solid #fbbf24;
  outline-offset: 2px;
}
.jm-ctx__thumb {
  display: grid;
  place-items: center;
  aspect-ratio: 4 / 3;
  border-radius: 8px;
  background: #343b4b;
  color: #e8ebf2;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .1em;
}
.jm-ctx__file[data-kind="pdf"] .jm-ctx__thumb {
  background: #5b2a2a;
}
.jm-ctx__file[data-kind="img"] .jm-ctx__thumb {
  background: #23485a;
}
.jm-ctx__file[data-kind="doc"] .jm-ctx__thumb {
  background: #3d3a63;
}
.jm-ctx__file[data-kind="vid"] .jm-ctx__thumb {
  background: #2f4f37;
}
.jm-ctx__menu {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 3;
  min-width: 180px;
  margin: 0;
  padding: 5px;
  list-style: none;
  border-radius: 10px;
  background: #fff;
  box-shadow: 0 18px 40px rgba(0, 0, 0, .4);
  color: #111827;
}
.jm-ctx__menu[hidden] {
  display: none;
}
.jm-ctx__menu [role="menuitem"] {
  display: flex;
  align-items: center;
  min-height: 34px;
  padding: 0 12px;
  border-radius: 6px;
  font-size: 14px;
  cursor: default;
}
.jm-ctx__menu [role="menuitem"]:hover {
  background: #f3f4f6;
}
.jm-ctx__menu [role="menuitem"]:focus {
  outline: none;
  background: #1f2430;
  color: #fff;
}
.jm-ctx__menu .jm-ctx__danger {
  color: #b91c1c;
}
.jm-ctx__menu .jm-ctx__danger:focus {
  background: #b91c1c;
  color: #fff;
}
.jm-ctx__menu [role="separator"] {
  height: 1px;
  margin: 5px 6px;
  background: #e5e7eb;
}
.jm-ctx__status {
  min-height: 22px;
  margin: 16px 0 0;
  color: #fcd34d;
  font-size: 14px;
}

Sort menu with radio items

  • menuitemradio
  • menuitemcheckbox
  • aria-checked

6 lamps

  • Alder desk lamp4.6$89
  • Fjell floor lamp4.8$149
  • Pebble night light4.1$39
  • Arc pendant4.4$219
  • Clamp reading lamp4.9$64
  • Dune table lamp3.9$119

Try the keyboard: arrow keys, Home, End, Escape, and typing the first letter of an item.

Sort options are mutually exclusive settings, which is what menuitemradio inside a labelled group is for, and the filter is a menuitemcheckbox. The button label shows the current choice. Space changes a setting and keeps the menu open, Enter or a click changes it and closes, and the list really re-sorts.

View code
.jm-sort {
  width: min(100%, 520px);
  min-height: 380px;
  color: #2a1f14;
}
.jm-sort__bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 12px;
}
.jm-sort__count {
  margin: 0;
  color: #5c4a38;
  font-size: 14px;
  font-weight: 600;
}
.jm-sort__wrap {
  position: relative;
  margin-left: auto;
}
.jm-sort__btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  min-height: 44px;
  padding: 0 16px;
  border: 1px solid #2a1f14;
  border-radius: 999px;
  background: #fff;
  color: #2a1f14;
  font: inherit;
  font-size: 14px;
  white-space: nowrap;
  cursor: pointer;
}
.jm-sort__btn span {
  font-weight: 700;
}
.jm-sort__btn::after {
  content: "";
  width: 6px;
  height: 6px;
  margin-left: 6px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-2px) rotate(45deg);
}
.jm-sort__btn[aria-expanded="true"] {
  background: #2a1f14;
  color: #fff;
}
.jm-sort__btn:focus-visible {
  outline: 3px solid #b45309;
  outline-offset: 2px;
}
.jm-sort__menu {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  z-index: 3;
  width: 230px;
  padding: 6px;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 0 0 1px #e7dccd, 0 18px 40px rgba(42, 31, 20, .18);
}
.jm-sort__menu[hidden] {
  display: none;
}
.jm-sort__menu [role^="menuitem"] {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 40px;
  padding: 0 12px 0 36px;
  border-radius: 9px;
  color: #2a1f14;
  font-size: 14.5px;
  cursor: pointer;
}
.jm-sort__menu [role^="menuitem"]:hover {
  background: #f7f1e9;
}
.jm-sort__menu [role^="menuitem"]:focus {
  outline: 2px solid #b45309;
  outline-offset: -2px;
  background: #f7f1e9;
}
.jm-sort__menu [role="menuitemradio"]::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 12px;
  width: 14px;
  height: 14px;
  margin-top: -8px;
  border: 1.5px solid #8a7562;
  border-radius: 50%;
}
.jm-sort__menu [role="menuitemradio"][aria-checked="true"]::before {
  border-color: #b45309;
  box-shadow: inset 0 0 0 3px #fff;
  background: #b45309;
}
.jm-sort__menu [role="menuitemcheckbox"]::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 12px;
  width: 14px;
  height: 14px;
  margin-top: -8px;
  border: 1.5px solid #8a7562;
  border-radius: 4px;
}
.jm-sort__menu [role="menuitemcheckbox"][aria-checked="true"]::before {
  border-color: #b45309;
  background: #b45309;
}
.jm-sort__menu [role="menuitemcheckbox"][aria-checked="true"]::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 17px;
  width: 4px;
  height: 8px;
  margin-top: -7px;
  border-right: 2px solid #fff;
  border-bottom: 2px solid #fff;
  transform: rotate(45deg);
}
.jm-sort__menu [role="separator"] {
  height: 1px;
  margin: 6px 4px;
  background: #ece3d7;
}
.jm-sort__list {
  display: grid;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.jm-sort__list li {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  align-items: center;
  gap: 14px;
  padding: 12px 16px;
  border-radius: 12px;
  background: #fff;
  font-size: 14.5px;
}
.jm-sort__list li[hidden] {
  display: none;
}
.jm-sort__list li span:nth-child(2) {
  color: #8a4a0b;
  font-size: 13px;
  font-weight: 600;
}
.jm-sort__list li span:nth-child(2)::before {
  content: "\2605  ";
}

Pickers that look like menus but are not

A command palette, a value picker and a docs sidebar all open a list, yet the right roles are combobox, listbox and tree. Picking the right one decides what a screen reader announces and which keys users expect.

Command palette

  • combobox + listbox
  • aria-activedescendant
  • Ctrl/Cmd+K

Click the search bar, or hover or focus this demo and press Ctrl+K (Cmd+K on a Mac).

A palette is a text box that filters a list, so the roles are combobox and listbox, not menu. Focus stays in the input while aria-activedescendant points at the highlighted option, and filtering compares textContent, never HTML. The shortcut listener only acts while the pointer or focus is inside this demo, so the browser keeps Ctrl+K everywhere else.

View code
.jm-cmd {
  position: relative;
  width: min(100%, 560px);
  min-height: 430px;
}
.jm-cmd__app {
  padding: 18px;
  border: 1px solid #262b38;
  border-radius: 16px;
  background: #141822;
}
.jm-cmd__open {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  min-height: 46px;
  padding: 0 10px 0 16px;
  border: 1px solid #343b4d;
  border-radius: 10px;
  background: #1c2130;
  color: #aab2c5;
  font: inherit;
  font-size: 14.5px;
  text-align: left;
  cursor: pointer;
}
.jm-cmd__open:hover {
  border-color: #4b5570;
}
.jm-cmd__open:focus-visible,
.jm-cmd__input:focus-visible {
  outline: 3px solid #a78bfa;
  outline-offset: 2px;
}
.jm-cmd__kbd {
  flex: none;
  padding: 3px 8px;
  border: 1px solid #3c4459;
  border-radius: 6px;
  color: #d4d9e6;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
}
.jm-cmd__result {
  min-height: 22px;
  margin: 14px 2px 0;
  color: #c4b5fd;
  font-size: 14px;
}
.jm-cmd__panel {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  z-index: 3;
  overflow: hidden;
  border: 1px solid #343b4d;
  border-radius: 14px;
  background: #1a1f2c;
  box-shadow: 0 30px 60px rgba(0, 0, 0, .55);
  color: #e6e9f2;
}
.jm-cmd__panel[hidden] {
  display: none;
}
.jm-cmd__input {
  display: block;
  width: 100%;
  box-sizing: border-box;
  min-height: 54px;
  padding: 0 18px;
  border: 0;
  border-bottom: 1px solid #2d3445;
  background: transparent;
  color: #f4f6fb;
  font: inherit;
  font-size: 16px;
}
.jm-cmd__input::placeholder {
  color: #8f98ad;
}
.jm-cmd__input:focus {
  outline: none;
}
.jm-cmd__list {
  max-height: 300px;
  margin: 0;
  padding: 6px;
  overflow-y: auto;
  list-style: none;
}
.jm-cmd__list [role="option"] {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 42px;
  padding: 0 12px;
  border-radius: 8px;
  font-size: 14.5px;
  cursor: pointer;
}
.jm-cmd__list [role="option"][hidden] {
  display: none;
}
.jm-cmd__list [role="option"]:hover {
  background: #232a3a;
}
.jm-cmd__list [aria-selected="true"] {
  background: #2e2850;
  box-shadow: inset 3px 0 0 #a78bfa;
}
.jm-cmd__list small {
  color: #9aa3b8;
  font-size: 12px;
}
.jm-cmd__empty {
  margin: 0;
  padding: 18px;
  color: #aab2c5;
  font-size: 14px;
}
.jm-cmd__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
@keyframes jm-cmd-in {
  from {
    opacity: 0;
    transform: scale(.97);
  }
}
@media (prefers-reduced-motion: no-preference) {
  .jm-cmd__panel:not([hidden]) {
    animation: jm-cmd-in .15s ease-out;
  }
}

Select-only combobox

  • role="combobox"
  • listbox
  • type-ahead

Workspace settings

Data region

Where files and backups are stored. Changes apply to new uploads.

Arrow keys move, Enter picks, typing filters or jumps to a match.

When a dropdown picks a value rather than running an action, it is a listbox, and the APG select-only combobox is the custom version of a select. It opens with Down, Enter, Space or a typed letter, moves with arrows, Home, End and type-ahead, commits on Enter or Tab and cancels on Escape. If a native select can be styled enough for the design, use that instead.

View code
.jm-sel {
  width: min(100%, 400px);
  min-height: 410px;
}
.jm-sel__card {
  padding: 22px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 0 0 1px #dfe5e2, 0 12px 30px rgba(17, 45, 34, .08);
  color: #12261d;
}
.jm-sel__card .jm-sel__heading {
  margin: 0 0 18px;
  color: #12261d;
  font-family: "Fraunces", serif;
  font-size: 20px;
  font-weight: 600;
}
.jm-sel__label {
  display: block;
  margin-bottom: 6px;
  font-size: 14px;
  font-weight: 700;
}
.jm-sel__field {
  position: relative;
}
.jm-sel__combo {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 46px;
  padding: 0 44px 0 14px;
  border: 1.5px solid #8aa096;
  border-radius: 10px;
  background: #fff;
  color: #12261d;
  font-size: 15px;
  cursor: pointer;
  user-select: none;
}
.jm-sel__combo::after {
  content: "";
  position: absolute;
  top: 50%;
  right: 18px;
  width: 7px;
  height: 7px;
  margin-top: -6px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
}
.jm-sel__combo[aria-expanded="true"]::after {
  margin-top: -1px;
  transform: rotate(-135deg);
}
.jm-sel__combo:focus-visible,
.jm-sel__combo[aria-expanded="true"] {
  border-color: #157347;
  outline: 3px solid #b7e4cc;
  outline-offset: 0;
}
.jm-sel__list {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  left: 0;
  z-index: 3;
  max-height: 232px;
  margin: 0;
  padding: 5px;
  overflow-y: auto;
  list-style: none;
  border-radius: 10px;
  background: #fff;
  box-shadow: 0 0 0 1px #dfe5e2, 0 18px 40px rgba(17, 45, 34, .16);
}
.jm-sel__list[hidden] {
  display: none;
}
.jm-sel__list [role="option"] {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 38px;
  padding: 0 12px 0 34px;
  border-radius: 7px;
  font-size: 14.5px;
  cursor: pointer;
}
.jm-sel__list [role="option"]:hover {
  background: #eef5f1;
}
.jm-sel__list .jm-sel__opt--active {
  background: #157347;
  color: #fff;
}
.jm-sel__list .jm-sel__opt--active:hover {
  background: #157347;
}
.jm-sel__list [aria-selected="true"]::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 14px;
  width: 4px;
  height: 9px;
  margin-top: -7px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
}
.jm-sel__help {
  margin: 10px 0 0;
  color: #4f6158;
  font-size: 13px;
  line-height: 1.5;
}

Tree view navigation for docs

  • role="tree"
  • aria-owns
  • roving tabindex

Docs

Quick start

Pick a page in the tree. With the keyboard, Tab into the tree once, then use the arrow keys.

Arrow keys move, Enter picks, typing filters or jumps to a match.

A deep docs sidebar can be a tree, following the APG navigation treeview: links carry role="treeitem", parents have aria-expanded, and the whole tree is one Tab stop. Up and Down walk visible items, Right opens or steps in, Left closes or steps out, and a letter jumps ahead. Use a tree only when users benefit from those keys; a plain nested list of links is simpler for short sidebars.

View code
.jm-tree {
  display: grid;
  grid-template-columns: 250px minmax(0, 1fr);
  gap: 18px;
  width: min(100%, 720px);
  color: #172033;
}
.jm-tree__nav {
  padding: 16px 10px;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 0 0 1px #e1e5ec;
}
.jm-tree__nav .jm-tree__title {
  margin: 0 0 10px 10px;
  color: #172033;
  font-size: 15px;
  font-weight: 800;
}
.jm-tree__nav ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.jm-tree__nav ul ul {
  margin-left: 14px;
  padding-left: 8px;
  border-left: 1px solid #e1e5ec;
}
.jm-tree__nav ul[hidden] {
  display: none;
}
.jm-tree [role="treeitem"] {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 34px;
  padding: 0 10px 0 26px;
  border-radius: 7px;
  color: #33405a;
  font-size: 14.5px;
  text-decoration: none;
}
.jm-tree [role="treeitem"]:not([aria-expanded]) {
  padding-left: 12px;
}
.jm-tree [role="treeitem"][aria-expanded]::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 10px;
  width: 5px;
  height: 5px;
  margin-top: -4px;
  border-top: 2px solid currentColor;
  border-right: 2px solid currentColor;
  transform: rotate(45deg);
}
.jm-tree [role="treeitem"][aria-expanded="true"]::before {
  margin-top: -5px;
  transform: rotate(135deg);
}
.jm-tree [role="treeitem"]:hover {
  background: #f1f4f9;
}
.jm-tree [role="treeitem"][aria-current="page"] {
  background: #e8efff;
  color: #1d4ed8;
  font-weight: 700;
}
.jm-tree [role="treeitem"]:focus-visible {
  outline: 2px solid #1d4ed8;
  outline-offset: -2px;
}
.jm-tree__page {
  align-self: start;
  padding: 22px 24px;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 0 0 1px #e1e5ec;
}
.jm-tree__page p {
  margin: 0;
}
.jm-tree__page .jm-tree__crumb {
  color: #5b6680;
  font-size: 13px;
}
.jm-tree__page .jm-tree__h {
  margin: 4px 0 10px;
  color: #172033;
  font-family: "Fraunces", serif;
  font-size: 26px;
  font-weight: 600;
}
.jm-tree__page .jm-tree__text {
  color: #3a4560;
  font-size: 14.5px;
  line-height: 1.6;
}
@media (max-width: 640px) {
  .jm-tree {
    grid-template-columns: minmax(0, 1fr);
  }
}
@media (prefers-reduced-motion: no-preference) {
  .jm-tree [role="treeitem"][aria-expanded]::before {
    transition: transform .15s ease, margin-top .15s ease;
  }
}

What are JavaScript menus, and how do they differ from CSS-only menus?

Script is the difference. It takes over open and close state, event handling, keyboard focus and the ARIA attributes. Take it away and you have a pure CSS dropdown menu, which handles :hover and :focus-within and little else.

The HTML holds the links and CSS controls visibility, with script sitting on top to hold the state. It doesn’t take much code either. A basic open and close toggle is a few lines of plain JavaScript.

CSS can show a submenu on hover or focus, style the open and closed states, and animate the panel with transitions. That’s about where it stops.

Everything else needs script. Escape should close the panel and send focus back to its trigger button. aria-expanded has to be updated whenever the state changes. A click outside the menu should close it, and if you only want one submenu open at a time, that logic is yours to write.

The select-based jump menu looks similar but isn’t really a navigation menu. It’s a form control that loads the chosen URL from a change handler, so it stops working when scripting fails.

Dropdown, mega, hamburger, accordion and context menus: which layout each one fits

The right pick depends on how many links each top-level item holds and how much screen width is left over.

TypeFitsUsual trigger
DropdownOne short list of links per top-level itemClick on a button
Mega menuLarge sites with grouped categoriesClick, tap or delayed hover
Hamburger (off-canvas)Narrow viewportsTap on a toggle button
AccordionSidebars and stacked mobile listsClick on a header button
Context menuActions on a selected item in an appRight click

Most builds start with a basic dropdown, a button that opens a list of links, driven by one state value. The other types reuse that toggle logic with more markup around it.

Mega menus change the trade-off. Nielsen Norman Group (Nielsen and Li, 2017) describes them as large two-dimensional panels with grouped options, all visible at once. A regular dropdown hides most choices and can’t group them without workarounds.

The hamburger menu puts all navigation behind one toggle button, so its whole job is state. The button flips aria-expanded and the off-canvas panel slides in. Save it for widths where the links no longer fit. For the icon and its animation, see these CSS hamburger menu examples.

Context menus sit outside site navigation. They open on right click and belong in application-style interfaces, which is where the ARIA menu role fits.

How to build a dropdown menu with JavaScript

Start with a button and a list of links. Keep the open state in aria-expanded and flip it from a single click handler. CSS reads the same attribute, so the visual state and the announced state stay in sync.

Markup and state

Plain HTML is enough here: a nav element with a list inside it, and a button sitting directly before the sub-list it controls.

State can live in a few places. classList.toggle adds or removes a class such as show. It’s simple, but a screen reader announces nothing when the class changes.

The hidden attribute removes the panel from rendering and from the accessibility tree. aria-expanded is a true or false value on the button, announced as expanded or collapsed, and CSS can read it with an attribute selector.

Make aria-expanded the single source of truth.

The W3C APG disclosure example does exactly this, and it styles both states with attribute selectors such as [aria-expanded=”true”].

Toggle script

  1. Select every button that carries aria-expanded and aria-controls.
  2. Attach one click listener to the nav element instead of one per button (event delegation).
  3. On click, read the button’s current aria-expanded value and flip it.
  4. Set or remove the hidden attribute on the controlled list so it matches.
  5. Close any other open dropdown before opening the new one.
  6. Close on Escape and when focus leaves the nav, and send focus back to the button.

The APG disclosure navigation example (revised January 2026) keeps its logic in a single file, disclosureMenu.js. Read it beside your own version, but note that the W3C labels the code as illustrative, not production-ready, and asks for testing with real assistive technology.

Which events should open and close a menu?

Click opens a menu on every input type, so bind it first. Hover is an add-on for desktop, and it needs a delay.

According to Baymard Institute (2023), a hover delay of 300 to 500 milliseconds removes most accidental triggering, and 60% of sites in its e-commerce benchmark still skip it.

Macy’s applies the delay, so the pointer can cross the main navigation without opening anything. John Lewis opens panels instantly, and the wrong category is easy to trigger.

Nielsen Norman Group’s timing guideline lands in the same range. Wait 0.3 to 0.5 seconds before showing hover content, display it within 0.1 seconds, and hold it until the pointer has been outside for 0.5 seconds. The closing grace period exists for the diagonal problem, where the pointer path briefly leaves the active area on its way to a menu item.

Click toggles the menu for mouse, touch and keyboard, because a button fires click on Enter and Space. mouseenter and mouseleave handle hover opening, always behind a delay timer. keydown covers Escape to close the panel, plus arrow keys if you add them, and focusout closes the panel once focus leaves the nav.

Hover-triggered panels fall under WCAG 2.2 success criterion 1.4.13 (Level AA). The panel has to be dismissible without moving the pointer. The pointer also needs to be able to travel onto the panel, and the panel should stay visible until the trigger is removed or the user dismisses it.

Touch screens have no hover at all. A hover-only menu gives those users no way in.

A click outside the menu closes it, and so does Escape. Add focus leaving the nav and you’ve covered most cases.

Disclosure pattern or menubar: which one fits site navigation?

Use the disclosure pattern for site navigation. The W3C APG recommends it for most websites because few sites need the extra keyboard behavior that the menubar and menu roles demand.

Both patterns rely on ARIA, but they make different promises to assistive technology. A menu role tells a screen reader the widget behaves like a desktop application menu, so users expect arrow keys everywhere.

KeyDisclosure navigationMenubar
TabMoves through every button and open linkOne stop for the whole bar (roving tabindex)
Enter or SpaceToggles a dropdown or follows a linkOpens a submenu or activates the item
EscapeCloses the dropdown, focus returns to its buttonCloses the submenu, focus returns to the parent item
Arrow keysOptionalRequired: Left and Right move along the bar, Up and Down open and move through submenus

Disclosure pattern

Each top-level item is a button, and the dropdown is a nested list of ordinary links.

The button carries aria-expanded set to true or false, and aria-controls points to the dropdown’s id. Put an aria-label on the nav so the landmark has a name.

Leave aria-haspopup off the buttons. The attribute announces a popup with the menu role, a promise this pattern does not keep (MDN states plainly that the menu role is not for site navigation).

In the APG example (last revised January 2026), Escape closes an open dropdown and returns focus to its button. The APG calls this behavior necessary to meet criterion 1.4.13.

Menubar pattern

Menu roles go wrong a lot in the wild. WebAIM’s 2026 web accessibility audit of one million home pages found role=”menu” on 5.7% of them, and 22% of those menus created barriers because the required markup and interactions were missing (WebAIM Million, 2026).

That’s a lot to ask of a widget. Arrow keys have to move focus, exactly one item can sit at tabindex 0 in a roving tabindex, and typing a character usually jumps to a matching item (optional in the APG). Miss any one and the widget breaks its own promise.

The APG still publishes a menubar navigation example, with a caution on top. Practitioners pushed for lighter options. Per Evinced’s comparison, Sarah Higley’s “Disclosure for Navigation Menus” was accepted into the APG as an alternative, and Adrian Roselli’s link plus disclosure widget approach is the third pattern in that comparison.

A menubar fits a real application menu, such as the APG’s editor menubar example. It does not fit a marketing site.

Button or link for each item

If an item opens a submenu, make it a button. If it goes to a URL, make it an anchor with an href.

A parent that is both a page and a section needs both. The APG documents this as the disclosure navigation menu with top-level links, which gives you a real link plus a separate disclosure button beside it.

Nielsen Norman Group reached the same layout from the usability side in 2017. Make each top-level choice clickable and send it to a regular page that lists every dropdown option.

Google Search Central adds the crawling angle. Google can generally crawl a link only if it is an a element with an href. Dropdown links stay crawlable as long as they are real anchors in the HTML, but a parent that is only a button gives crawlers no URL for the section itself.

How to switch a menu between desktop and mobile layouts

Pick the breakpoint where the links stop fitting in one row, then drive the CSS and the script from that same condition. When the condition flips, reset every open state so a resize never leaves a panel half open.

The stylesheet’s media queries switch the layout, and matchMedia hands the same condition to script. It returns a MediaQueryList with a matches property for the current state and a change event that fires when the viewport crosses the line.

There’s a syntax trap here. The feature must sit in parentheses, as in (width <= 600px), and without them the query string is invalid (MDN).Writing the stacked, closed layout first and adding the horizontal bar at the breakpoint follows a mobile-first approach, which keeps the base styles short.

Each time the viewport crosses the breakpoint, set aria-expanded back to false on every toggle button and put the hidden attribute back on each closed panel. Remove any body scroll lock, and if the panel that just closed held focus, return it to the trigger.

The off-canvas panel is where the inert attribute earns its place. A closed panel marked inert leaves the tab order and the accessibility tree, and the page behind an open one can be marked inert too (supported across browsers since April 2023 and Baseline widely available, per MDN).

MDN adds a warning that inert gives no visual cue by default, so style the dimmed state yourself.

WCAG 2.2 success criterion 2.5.8 (Level AA) sets a 24 by 24 CSS pixel minimum for pointer targets, with a spacing exception. The W3C’s own dropdown example passes at 24 pixel items and fails at 18.

MDN recommends 44 by 44 CSS pixels for buttons, so treat 24 as the floor and 44 as the target for mobile menu items.

Are JavaScript menus crawlable, and do they slow interaction?

Yes to both, with conditions. The anchors need to reach the rendered HTML, and a lean click handler adds almost no delay.

Link discovery

Google runs JavaScript with an evergreen version of Chromium, and Search Central (revised March 2026) says injecting links into the DOM with script is fine.

Rendering still sits in its own queue. A page may wait a few seconds there, or longer, so anchors in the initial HTML are found at crawl time while injected ones wait for the renderer.

Anchors in the server HTML are the safest, with script adding only the toggles. Anchors injected on load, before anyone interacts, also work, and so does an anchor that carries both an href and an onclick (Google lists it as parseable).

Some setups are riskier.

  • javascript: URLs in the href, which Google marks as not recommended
  • Hash routes such as #/products, where the History API is the better route
  • Submenu anchors fetched only after a hover or click, so verify those in the URL Inspection tool

A mega menu that loads its panels through an Ajax call on hover sits in that riskier group.

Google also notes that server-side or pre-rendering is still a good idea because not every bot runs JavaScript. A menu that keeps the whole site’s website navigation inside script has no fallback for those bots.

Interaction latency

Interaction to Next Paint rates a page good at 200 milliseconds or less and poor above 500, measured at the 75th percentile of page loads (web.dev).

INP counts clicks, taps and key presses. Hovering is not observed, so a hover-only menu slips past the metric while still failing touch users.

web.dev names a navigation menu opening as the kind of visual feedback the metric waits for. It times the wait until the next paint, not the network fetches that follow, so paint the open state first and load content after.

Long tasks already running when the tap lands will push a menu click past the limit. So will layout reads mixed between style writes (layout thrashing), an oversized DOM, which is common in mega menu panels, and a fetch awaited before the state changes.

Vanilla JavaScript, jQuery, frameworks and native elements compared

Choose the smallest option that covers the menu’s job. Plain script is enough for a one-level dropdown. A headless library earns its weight in a component-based app, and native elements are hard to beat when open and closed state is all the menu needs.

OptionWhat it providesAccessibility modelMain limit
Vanilla JavaScriptState, keys and dismissal you writeAs built; the APG disclosure example is the referenceYou own every edge case
Bootstrap 5.3 dropdownClick toggle, Esc and arrow keysNo ARIA menu roles addedPopper required outside navbars
Alpine.jsx-data, x-show and @click.outside in markupNone in the starter examplearia-expanded added by hand
Radix UI Navigation MenuReact primitive with managed focus, 11.89 kBAvoids the menu roleReact only
details and Popover APINative open state, no toggle scriptBrowser-suppliedPopovers are non-modal; details has no built-in animation

The Bootstrap dropdown plugin is the ready-made route, and its documentation is blunt about what it does. It toggles on click and never on hover, which the docs call an intentional design decision. Popper handles positioning except inside a navbar, and the autoClose option (true, false, inside or outside) decides what dismisses the panel.

Alpine’s starter dropdown is a handful of directives. An x-data flag holds the state, @click flips it, x-show hides the panel, and @click.outside closes it.

The docs example sets no aria-expanded, so bind it yourself.

Radix keeps Navigation Menu apart from its Dropdown Menu and Menubar components. Its Navigation Menu page says it skips the menu role on purpose, because composite focus management and first-character navigation are unnecessary for website navigation.

Headless UI lists Dropdown Menu beside Disclosure and Popover. Its Menu focuses the first item on open and, with the modal prop on by default, traps focus and makes the rest of the page inert, so for main navigation Disclosure is the closer fit.

Floating UI solves positioning and viewport collisions, and its React package adds hooks for hover, focus, click and dismiss. It is modular and tree-shakeable, so bundle size tracks what you import.

jQuery only makes sense where the site already ships it. Bootstrap 5.3’s plugin starts from plain JavaScript or data attributes, so a new build gains nothing by adding jQuery for one dropdown.

Native elements cover more than most builds assume. The details element has worked in every major browser since January 2020 (Baseline widely available since July 2022) and keeps its state in an open attribute, and giving grouped elements the same name turns them into an exclusive accordion without script.

The Popover API (Baseline newly available since January 2025) turns a popovertarget button and a popover element into a toggle, and the browser adds the aria-expanded relationship for you.

When a JavaScript menu fails or is the wrong choice

A JavaScript menu fails when its script never runs, when browsers disagree about focus or taps, when the panel sits under another layer, or when the site never needed a dropdown at all.

Failure conditions

  • If the links exist only after script runs, a failed load leaves an empty navigation bar. Keep the anchors in the HTML and let script add the toggles.
  • MDN states that Safari does not focus a button on click, by design, so a close rule built only on focusout misses clicks there. Add a document click listener as the backup.
  • Bootstrap’s documentation describes a quirk in iOS event delegation that stops a tap on empty page area from reaching the closing handler. The plugin works around it by attaching empty mouseover handlers to the body’s children while a dropdown is open.
  • A panel with a high z-index can still sit under a sibling’s stacking context. Popover elements avoid this because showPopover places them in the top layer (MDN).
  • Absolutely positioned panels spill off screen near the edges, which is the problem Floating UI’s flip and shift middleware exist to solve.

Treat the Safari and iOS items as routine cross-browser compatibility checks, not edge cases.

One wrong-fit case needs no fix. When the top-level pages fit in a single visible row, skip the dropdown. A flat list of links has no state to manage, so there is no menu to break.

How to test

  1. Keyboard only: Tab to every toggle, open with Enter and Space, close with Escape, and confirm focus lands back on the button.
  2. Screen reader pass with NVDA on Windows and VoiceOver on macOS and iOS. The APG warns of support gaps in some browser and assistive technology combinations, especially on mobile and touch devices.
  3. Touch pass on a real iPhone or iPad, where the iOS event quirk lives: tap a toggle, tap empty page area, tap a link.
  4. Automated scans: axe DevTools for ARIA and naming errors, and Lighthouse, whose SEO category reports “Links are not crawlable” for anchors without a valid href.

Scans pass or fail, but they cannot tell you whether Escape returned focus to the right button. That check stays manual.

JavaScript Menus FAQ

How do I animate a JavaScript menu?

Use CSS transitions on opacity and transform, keyed to the aria-expanded value so the animation and the announced state change together.

The hidden attribute removes the panel from rendering, so swap it for visibility rules when you want a transition to play.

Should a menu be horizontal or vertical?

Horizontal fits a top bar with a few sections, and vertical fits sidebars and deep trees.

The choice also changes keyboard mapping, since Radix’s Navigation Menu moves focus into an open panel with ArrowDown when horizontal, and with ArrowRight or ArrowLeft when vertical.

Can menu items hold icons or images?

Yes. Mega menus have room for images, according to Nielsen Norman Group (Nielsen and Li, 2017).

An icon-only button has no accessible name, so pair each icon with visible text (MDN), and mark purely decorative icons aria-hidden.

Does a sticky header need JavaScript?

No. CSS position: sticky keeps a header in view, and a scroll listener only adds main-thread work that competes with the click handler.

The sticky navigation pattern still needs a small-viewport check, because a fixed bar takes space from content.

How do I build a right-click context menu?

Listen for the contextmenu event, call preventDefault, and place the panel at the pointer coordinates.

Mouse-only menus exclude keyboard users, so offer a keyboard route to the same actions, and let Escape close the panel and return focus to the element that opened it (MDN).

How deep can nested submenus go?

Two levels is a sound ceiling, and the APG disclosure navigation example stops there.

Repeat the button and list pair at each level, give every button its own aria-expanded, and close the innermost open panel first on Escape.

Nielsen Norman Group advises keeping mega menus simple, so flatten the structure before adding a third level.

Fixing Broken JavaScript Menus in the Right Order

When a menu fails several checks at once, repair the markup first. Fix the keyboard behavior next and leave the touch layout for last.

Markup leads because swapping a div for an a or button element changes what the event handlers and ARIA attributes attach to, and writing those twice wastes the work.

That means links become a elements and toggles become button elements. After that comes Escape, Tab and arrow key behavior, with focus returning to the trigger, and then the breakpoint switch and touch target sizing.

The trade-off is that the phone layout stays rough the longest, because the foundation gets fixed before the visible styling.