<header>
  <button class="mobile-menu-button hidden-desktop" aria-expanded="false" aria-controls="main-nav">Mobile menu</button>
  <nav id="main-nav" class="hidden-mobile">
    <ul>
      <li><a href="/about-us">About us</a></li>
      <li>
        <button class="expand-button" aria-expanded="false" aria-controls="products-level-2">
          Products
          <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="12" height="9" viewBox="0 0 12 9">
            <polygon points="1 0, 11 0, 6 8"></polygon>
          </svg>
        </button>
        <ul id="products-level-2" class="hidden">
          <li><a href="/products/product1">Product 1</a></li>
          <li><a href="/products/product2">Product 2</a></li>
          <li>
            <button class="expand-button" aria-expanded="false" aria-controls="products-level-3">Sub
              <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="12" height="9" viewBox="0 0 12 9">
                <polygon points="1 0, 11 0, 6 8"></polygon>
              </svg>
            </button>
            <ul id="products-level-3" class="hidden">
              <li><a href="/products/reusable-product1">Sub 1</a></li>
              <li><a href="/products/reusable-product2">Sub 2</a></li>
            </ul>
          </li>
        </ul>
      </li>
      <li><a href="/insights">Insights</a></li>
      <li><a href="/contact">Contact</a></li>
    </ul>
  </nav>
</header
header {
  font-family: Arial;

  .mobile-menu-button {
    @media (min-width: 48em) {
      display: none;
    }
  }

  nav {
    transition: opacity 0.3s, visibility 0.3s;

    &:not(.hidden-mobile) {
      opacity: 1;
    }

    ul {
      list-style: none;
      padding-left: 0;

      > li {
        margin-left: 0;

        > button {
          appearance: none;
          background: none;
          border: 0;

          + ul {
            transition: opacity 0.3s, visibility 0.3s;
          }

          &[aria-expanded="true"] > svg {
            transform: rotate(180deg);
          }

          &[aria-expanded="false"] + ul {
            opacity: 0;
            height: 0;
          }
        }
      }
    }

    > ul {
      margin-right: -0.5rem;
      margin-left: -0.5rem;
      flex-wrap: wrap;
      display: flex;

      > li {
        margin-right: 0.5rem;
        margin-left: 0.5rem;

        @media (max-width: 48em) {
          width: 100%;
        }

        > a,
        > button {
          font-size: 1.25rem;
        }

        a,
        button {
          display: inline-block;
          padding: 0.25rem;
          color: inherit;

          &[aria-current="page"] {
            color: blue;
          }

          &[aria-current="true"] {
            background-color: lightgrey;
          }

          &:hover,
          &:focus-visible {
            text-decoration: underline;
            cursor: pointer;
          }
        }

        a {
          &:not(:hover):not(:focus-visible) {
            text-decoration: none;
          }
        }
      }
    }
  }
}

.hidden {
  visibility: hidden;
}

.hidden-mobile {
  @media (max-width: 48em) {
    visibility: hidden;
    opacity: 0;
    height: 0;
  }
}
View Compiled
class ExpandButtonFactory {
  public static instances: ExpandButton[] = [];

  static create(selector: string, customHiddenClass?: string) {
    this.instances = [
      ...this.instances,
      ...Array.from(document.querySelectorAll(selector)).map((el: Element) => {
        if (!(el instanceof HTMLElement)) {
          throw new TypeError("No HTML element found.");
        }

        return new ExpandButton(el as HTMLElement, customHiddenClass);
      })
    ];
  }

  static addWindowListener() {
    window.addEventListener("keydown", (e) => {
      if (e.key === "Escape") {
        ExpandButtonFactory.instances.forEach((expandButton: ExpandButton) => {
          if (
            document.activeElement
              ?.closest("ul")
              ?.closest("li")
              ?.contains(expandButton.el)
          ) {
            expandButton.collapse();
            expandButton.el.focus();
          }
        });
      }
    });
  }
}

class ExpandButton {
  el: HTMLElement;

  #isAriaExpanded: boolean = false;
  #ariaControlsElement: HTMLElement | null;
  #firstActionElement: HTMLElement | null;
  #hiddenClass: string = "hidden";

  constructor(el: HTMLElement, customHiddenClass?: string) {
    this.el = el;

    this.isAriaExpanded = this.el.getAttribute("aria-expanded") === "true";
    const ariaControlsAttr = this.el.getAttribute("aria-controls");
    this.#ariaControlsElement = ariaControlsAttr
      ? document.getElementById(ariaControlsAttr)
      : null;

    if (!(this.#ariaControlsElement instanceof HTMLElement)) {
      throw new TypeError("No referenced element found.");
    }

    this.#firstActionElement = this.#ariaControlsElement.querySelector(
      "a[href]:not([disabled]), button:not([disabled])"
    );

    if (!(this.#firstActionElement instanceof HTMLElement)) {
      throw new TypeError("No first actionable element found.");
    }

    if (customHiddenClass) {
      this.#hiddenClass = customHiddenClass;
    }

    this.initListeners();
  }

  get isAriaExpanded() {
    return this.#isAriaExpanded;
  }

  set isAriaExpanded(value) {
    this.#isAriaExpanded = value;
    this.el.setAttribute("aria-expanded", this.isAriaExpanded.toString());

    if (this.isAriaExpanded) {
      this.#ariaControlsElement?.classList.remove(this.#hiddenClass);

      setTimeout(() => {
        // focus on first actionable element within the ref element
        this.#firstActionElement?.focus();
      }, 10);
    } else {
      this.#ariaControlsElement?.classList.add(this.#hiddenClass);
    }
  }

  initListeners() {
    this.clickHandler();
    this.collapseOnBlurHandler();
  }

  collapse() {
    this.isAriaExpanded = false;
  }

  expand() {
    this.isAriaExpanded = true;
  }

  toggle() {
    this.isAriaExpanded = !this.isAriaExpanded;
  }

  clickHandler() {
    this.el.addEventListener("mousedown", (e) => {
      e.preventDefault();
      this.toggle();
    });

    this.el.addEventListener("keydown", (e) => {
      if (e.key === "Enter" || e.key === "Space") {
        e.preventDefault();
        this.toggle();
      }
    });
  }

  collapseOnBlurHandler() {
    if (this.el.classList.contains("mobile-menu-button")) {
      return;
    }

    (this.#ariaControlsElement as HTMLElement).addEventListener(
      "focusout",
      (e: Event) => {
        const currentTarget = e.currentTarget as HTMLElement;

        requestAnimationFrame(() => {
          if (!currentTarget.contains(document.activeElement)) {
            this.collapse();
          }
        });
      }
    );
  }
}

const mobileMenuButton = ExpandButtonFactory.create(
  ".mobile-menu-button",
  "hidden-mobile"
);

const menuItemButtons = ExpandButtonFactory.create(".expand-button");

ExpandButtonFactory.addWindowListener();
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/typescript/5.3.2/typescript.min.js