Build Accessible JavaScript Dropdown Menus From Scratch

Build Accessible JavaScript Dropdown Menus From Scratch

16 min read
Static Forms Team

You can build a dropdown in ten minutes and still ship a broken component.

That usually happens the same way. The first version works with a mouse. The menu opens, closes, and looks fine in a local demo. Then QA tabs into it and gets stuck. A screen reader announces something unhelpful. Mobile users tap near the trigger and the page jumps. Suddenly a tiny UI pattern turns into a real frontend problem.

Production-ready javascript dropdown menus aren't hard because the toggle is complex. They're hard because you have to make state, focus, semantics, and layout behave together.

Why Building a Good Dropdown is Harder Than It Looks

A dropdown usually starts as a small task in a sprint. Add a trigger, show a panel, hide it on outside click, ship it. Then the edge cases show up. Keyboard users expect predictable focus movement. Screen reader users need clear state changes. Mobile users need tap targets that do not misfire. The layout needs to stay stable while the menu opens.

That gap between demo code and production code is where dropdowns get expensive.

Native <select> elements set the baseline. Browsers already handle focus, keyboard input, selection, and a lot of accessibility behavior for you. The moment you replace that with a custom menu, your code owns all of it. That is a fair trade when the design calls for richer content, grouped actions, icons, async loading, or app-style interactions. It is a bad trade if the custom version only recreates a plain select with more bugs.

A good dropdown also sits across several layers of the stack at once. HTML defines what the trigger and items are. CSS decides whether the panel overlays cleanly or causes layout shift. JavaScript controls open state, outside interactions, and focus return. Accessibility work is not a final pass. It shapes the component from the first line of markup.

The common failure pattern is familiar:

  • The trigger only changes visuals: The menu appears on screen, but aria-expanded never updates, so assistive tech gets stale state.
  • The popup uses generic elements: A clickable div adds styling freedom but removes built-in behavior, which means more code to recreate keyboard and focus handling.
  • The menu affects document flow: Opening the panel pushes nearby content down, causing visible layout shift that feels rough, especially on mobile.
  • The close logic is incomplete: The component handles trigger clicks but misses Escape, outside pointer interactions, or focus leaving the menu.

I usually treat dropdowns as interaction systems, not styling exercises. That mindset changes implementation choices early. For example, hover can feel fast on desktop navigation, but click is easier to make reliable across touch devices, keyboard use, and mixed-input laptops. Absolute positioning can prevent content jumps, but it also introduces stacking, clipping, and viewport collision problems that need deliberate CSS. If you want to sharpen those layout decisions before touching JavaScript, this guide's articles on CSS menu and layout patterns are a useful reference.

The hard part is not the toggle. The hard part is making semantics, focus, events, and rendering agree under real usage. That is what separates a dropdown that survives QA from one that only works in a demo.

Laying a Solid HTML and CSS Foundation

A durable dropdown starts before JavaScript. If your markup is vague, the script becomes a cleanup crew for structural problems it shouldn't have to solve.

For a custom menu, the trigger should be a real <button>. The popup can be a <ul> with actionable items inside. If the menu items lead to other pages, use links. If they trigger actions, use buttons. Don't make a clickable div and then spend the afternoon patching missing behavior.

A hand-drawn hierarchical diagram illustrating the semantic structure of a navigation menu using HTML tags.

Use markup that explains itself

HTML
<div class="dropdown" data-dropdown>
  <button
    class="dropdown__trigger"
    id="account-menu-button"
    type="button"
    aria-haspopup="true"
    aria-expanded="false"
    aria-controls="account-menu"
  >
    Account
  </button>

  <ul
    class="dropdown__menu"
    id="account-menu"
    role="menu"
    aria-labelledby="account-menu-button"
    hidden
  >
    <li role="none">
      <a href="/profile" role="menuitem" class="dropdown__item">Profile</a>
    </li>
    <li role="none">
      <a href="/billing" role="menuitem" class="dropdown__item">Billing</a>
    </li>
    <li role="none">
      <button type="button" role="menuitem" class="dropdown__item">
        Sign out
      </button>
    </li>
  </ul>
</div>

This structure does a few important things well:

  • The trigger is focusable by default
  • The menu has an ID for aria-controls
  • Items remain real interactive elements
  • List markup still makes sense even if styling fails

If you're building site navigation rather than an action menu, use nav, links, and a simpler semantic structure. Don't force role="menu" onto every dropdown just because it sounds accessible. Application menus and navigation menus aren't the same pattern.

CSS should overlay, not reflow

A dropdown should appear on top of the document, not push nearby content down. That means the container needs a positioning context and the panel needs absolute positioning.

CSS
.dropdown {
  position: relative;
  display: inline-block;
}

.dropdown__trigger {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
}

.dropdown__menu {
  position: absolute;
  top: calc(100% + 0.5rem);
  left: 0;
  min-width: 14rem;
  margin: 0;
  padding: 0.5rem 0;
  list-style: none;
  background: white;
  border: 1px solid #d0d7de;
  border-radius: 0.5rem;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);
  opacity: 0;
  visibility: hidden;
  transform: translateY(-4px);
  pointer-events: none;
  transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s ease;
  z-index: 1000;
}

.dropdown.is-open .dropdown__menu {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
  pointer-events: auto;
}

.dropdown__item {
  display: block;
  width: 100%;
  padding: 0.75rem 1rem;
  text-align: left;
  text-decoration: none;
  color: #111827;
  background: transparent;
  border: 0;
}

.dropdown__item:hover,
.dropdown__item:focus-visible {
  background: #f3f4f6;
  outline: none;
}

That pattern avoids a common mistake. Developers often switch display: none to display: block and call it done. It works, but it gives you clunky transitions and can make state harder to coordinate. A controlled combination of hidden, visibility styles, and a container state class tends to age better.

Practical rule: Reserve space for the trigger, not for the menu. The menu should float over content, not participate in normal page flow.

A few CSS decisions save a lot of cleanup later

Here's where teams usually get into trouble:

Choice What works What causes pain
Trigger element Real <button> Clickable div
Menu positioning position: absolute inside a positioned parent Letting the menu expand in document flow
Visibility State class plus controlled hidden state Inline styles scattered through JavaScript
Focus styling :focus-visible with strong contrast Removing outlines without replacement

If you're working on marketing sites, docs sites, or low-code builds, keep the CSS simple and predictable. A small utility layer is enough. If you want more patterns for layout and transitions, Static Forms also publishes frontend articles in its CSS tag archive.

Implementing Core JavaScript Toggle Logic

A dropdown usually feels done after the first successful click. Then the underlying bugs show up. The menu stays open after an outside click, aria-expanded gets out of sync, focus lands in the wrong place, or a second dropdown on the page breaks the first one.

That is why the toggle logic needs more structure than a quick class flip. The goal is simple: one source of truth for open state, and every side effect tied to that state.

A hand-drawn diagram illustrating how a menu button click triggers a JavaScript toggle to show a dropdown.

Build around explicit open and close functions

Keep the behavior boring and predictable. Separate openMenu() and closeMenu() functions are easier to test, easier to debug, and easier to port into React, Vue, Alpine, or plain server-rendered pages.

HTML
<div class="dropdown" data-dropdown>
  <button
    class="dropdown__trigger"
    id="actions-button"
    type="button"
    aria-haspopup="true"
    aria-expanded="false"
    aria-controls="actions-menu"
  >
    Actions
  </button>

  <ul
    class="dropdown__menu"
    id="actions-menu"
    role="menu"
    aria-labelledby="actions-button"
    hidden
  >
    <li role="none"><a href="/edit" role="menuitem" class="dropdown__item">Edit</a></li>
    <li role="none"><a href="/duplicate" role="menuitem" class="dropdown__item">Duplicate</a></li>
    <li role="none"><button type="button" role="menuitem" class="dropdown__item">Archive</button></li>
  </ul>
</div>
JavaScript
const dropdown = document.querySelector('[data-dropdown]');
const trigger = dropdown.querySelector('.dropdown__trigger');
const menu = dropdown.querySelector('.dropdown__menu');

function openMenu() {
  dropdown.classList.add('is-open');
  trigger.setAttribute('aria-expanded', 'true');
  menu.hidden = false;
}

function closeMenu() {
  dropdown.classList.remove('is-open');
  trigger.setAttribute('aria-expanded', 'false');
  menu.hidden = true;
}

function toggleMenu() {
  const isOpen = dropdown.classList.contains('is-open');
  if (isOpen) {
    closeMenu();
  } else {
    openMenu();
  }
}

trigger.addEventListener('click', toggleMenu);

The toggle itself is not the hard part. Keeping visual state, ARIA state, and document behavior aligned is where production code usually goes wrong.

Outside click handling belongs in the first version

Users expect a dropdown to clean itself up. If they click somewhere else and the menu stays open, the component feels half-finished.

JavaScript
document.addEventListener('click', (event) => {
  const clickedInside = dropdown.contains(event.target);

  if (!clickedInside) {
    closeMenu();
  }
});

Two details matter here.

Use contains() instead of checking for a class name on event.target. Class-based checks break as soon as someone wraps the button text in a span or changes the markup during a redesign. Also make sure document-level listeners are cleaned up when components unmount in React or Vue. A dropdown that leaks listeners will work fine in a demo and become noisy in a real app.

If you build a lot of interactive components, these JavaScript frontend articles are a useful reference for keeping event handling small and maintainable.

Event delegation scales better than per-item listeners

Menus rarely stay small. Product teams add permissions, conditional items, async actions, and feature flags. Binding one listener per item works, but it creates more setup and more cleanup for no real gain.

JavaScript
menu.addEventListener('click', (event) => {
  const item = event.target.closest('.dropdown__item');
  if (!item) return;

  closeMenu();
});

That pattern handles links and buttons without extra wiring. It also keeps dynamic menu content cheap to update, because the listener stays attached to the menu container.

Focus behavior should be intentional

A click-open menu needs a focus rule. Otherwise keyboard users tab into unpredictable places, and screen reader users get state changes without a clear interaction path.

JavaScript
function openMenu() {
  dropdown.classList.add('is-open');
  trigger.setAttribute('aria-expanded', 'true');
  menu.hidden = false;

  const firstItem = menu.querySelector('[role="menuitem"]');
  if (firstItem) firstItem.focus();
}

I use this pattern for action menus, where opening the menu usually means the user wants to choose an item right away. For a site navigation dropdown, keeping focus on the trigger until the user presses ArrowDown can be the better choice. Both are valid. Pick one interaction model and apply it consistently.

A quick visual walkthrough helps here:

Click beats hover as the foundation

Hover menus can look polished in a static mockup. They are less reliable in production. Touch devices do not have hover, pointer gaps can collapse the menu, and accidental opens become common in dense nav bars.

Start with click. Then add hover only as an enhancement for pointer devices if the design really benefits from it.

A practical setup looks like this:

  • Click opens and closes the menu
  • Outside click closes it
  • Focused interaction follows the same state model
  • Hover, if you add it, never becomes the only way to reveal content

That gives you one state machine instead of separate mouse, touch, and keyboard behavior that drift apart over time.

Mastering Accessibility with ARIA and Keyboard Navigation

A dropdown isn't accessible because it has ARIA attributes. It's accessible when people can understand it, operate it, and recover from mistakes with the keyboard and assistive tech they already use.

The markup does part of the job, but the behavior completes it.

A five-point accessibility checklist for developers to create accessible dropdown menus using ARIA attributes and keyboard support.

ARIA that supports real behavior

A few attributes do most of the heavy lifting when used correctly:

  • aria-haspopup="true" tells assistive tech the trigger opens a popup-style interface.
  • aria-expanded="false" or true communicates current state. This must change with the UI.
  • aria-controls="menu-id" links the trigger to the controlled element.
  • role="menu" identifies the popup when you're building an application-style menu.
  • role="menuitem" marks each actionable item inside that menu.

Those attributes are useful only if the interaction model matches them. If your component behaves like plain site navigation, keep the semantics simpler. Over-ARIA is a real problem. A fake app menu in a normal header nav can become more confusing than a well-structured list of links.

Keyboard support isn't optional

Mouse users can recover from a lot. Keyboard users can't if the component never learned their input model.

A solid baseline for javascript dropdown menus includes these keys:

Key Expected behavior
Enter or Space on trigger Open the menu
Escape Close the menu and return focus to the trigger
ArrowDown Move to the next item
ArrowUp Move to the previous item
Tab and Shift+Tab Move focus predictably, usually out of the menu unless you're intentionally trapping it

For most dropdown menus, you don't need a full focus trap like a modal dialog uses. You do need deliberate focus management.

A practical keyboard implementation

JavaScript
const items = () => [...menu.querySelectorAll('[role="menuitem"]')];

function focusItem(index) {
  const menuItems = items();
  if (!menuItems.length) return;

  const boundedIndex = (index + menuItems.length) % menuItems.length;
  menuItems[boundedIndex].focus();
}

trigger.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
    event.preventDefault();
    openMenu();
    focusItem(0);
  }

  if (event.key === 'ArrowUp') {
    event.preventDefault();
    openMenu();
    focusItem(items().length - 1);
  }
});

menu.addEventListener('keydown', (event) => {
  const menuItems = items();
  const currentIndex = menuItems.indexOf(document.activeElement);

  if (event.key === 'Escape') {
    event.preventDefault();
    closeMenu();
    trigger.focus();
  }

  if (event.key === 'ArrowDown') {
    event.preventDefault();
    focusItem(currentIndex + 1);
  }

  if (event.key === 'ArrowUp') {
    event.preventDefault();
    focusItem(currentIndex - 1);
  }

  if (event.key === 'Home') {
    event.preventDefault();
    focusItem(0);
  }

  if (event.key === 'End') {
    event.preventDefault();
    focusItem(menuItems.length - 1);
  }
});

That gets you a long way without turning the component into a framework of its own.

Accessibility check: If you unplug your mouse and the menu becomes frustrating within thirty seconds, it isn't done.

Focus return is part of the experience

Closing the menu should restore focus to the trigger in most cases. That's not just a convenience. It preserves orientation.

Users who rely on keyboards build a mental map from focus location. If your menu closes and focus disappears, you've forced them to search the page again. That feels minor in a demo and exhausting in a real product.

Typeahead and long lists

When a menu has many items, arrow-key navigation alone gets tedious. A common improvement is typeahead. Users press a key, and focus jumps to the next matching item label.

JavaScript
menu.addEventListener('keydown', (event) => {
  if (event.key.length !== 1) return;

  const letter = event.key.toLowerCase();
  const menuItems = items();

  const match = menuItems.find((item) =>
    item.textContent.trim().toLowerCase().startsWith(letter)
  );

  if (match) {
    match.focus();
  }
});

This is especially useful when product teams keep adding actions to a compact menu. At that point, accessibility and usability are the same problem.

Test with real tools, not assumptions

At minimum, test the dropdown this way:

  • Keyboard only: Open, move through, activate, and close everything.
  • Screen reader pass: Check whether the trigger announces expanded state sensibly.
  • Zoomed layout: Make sure focus styles remain visible and clipping doesn't hide the panel.
  • Touch device check: Confirm the trigger target is large enough and the panel doesn't appear partially offscreen.

A dropdown can look polished and still fail basic use. That's why accessibility work belongs in the first implementation, not the cleanup sprint.

Advanced Patterns and Performance Tuning

The easy dropdown is one trigger and one menu. Real projects usually ask for more. Country and city selectors. Account menus with grouped actions. Nested navigation. Menus rendered inside sticky headers. That complexity is where many demos fall apart.

One pattern shows up constantly in forms: dependent dropdowns. Choosing one value changes the options in the next. In ecommerce, guided selection like country-to-city can meaningfully reduce form abandonment — it's one of the more impactful UX improvements in checkout flows according to Baymard Institute research.

A hand-drawn diagram illustrating a website navigation hierarchy with a main menu, submenus, and sub-submenu.

Dynamic options from JSON

A straightforward country and city setup can be driven by JSON:

JavaScript
let jsonData = {};

fetch('countries.json')
  .then((response) => response.json())
  .then((data) => {
    jsonData = data;

    const countryDD = document.querySelector('select[name="country"]');

    for (let country in jsonData) {
      const opt = document.createElement('option');
      opt.value = country;
      opt.text = country;
      countryDD.add(opt);
    }
  })
  .catch(() => {
    console.error('JSON load failed');
  });

Then populate the second dropdown when the first changes:

JavaScript
const countryDD = document.querySelector('select[name="country"]');
const cityDD = document.querySelector('select[name="city"]');

cityDD.disabled = true;

countryDD.addEventListener('change', () => {
  cityDD.innerHTML = '<option>Select city</option>';

  if (!countryDD.value) {
    cityDD.disabled = true;
    return;
  }

  cityDD.disabled = false;

  for (let city of jsonData[countryDD.value]) {
    const opt = document.createElement('option');
    opt.value = city;
    opt.text = city;
    cityDD.add(opt);
  }
});

This pattern is often better as native <select> elements than as a custom action menu. That's an important distinction. If the user is filling a form, use native controls unless you have a strong reason not to.

Performance problems usually start in the DOM

The obvious bugs are easy to spot. The slower ones show up after the component ships.

Here are the trade-offs that matter most in production:

  • Event delegation beats many listeners: One listener on the menu container is easier to manage than binding every item individually.
  • Loading states matter for remote data: If fetch is slow and the UI looks frozen, users think the field is broken.
  • Large option sets need restraint: Very long custom menus can become expensive to render and awkward to use.
  • Cleanup matters in framework code: If you attach document listeners and forget to remove them, menus leak behavior across route changes.

Nested menus are where simple demos go to die. Keep hierarchy shallow unless the information architecture really demands it.

Preventing layout shift and jank

Mobile performance deserves special attention. Google's 2024 Core Web Vitals data indicates that 25% of mobile pages exceed CLS thresholds due to dynamically appearing UI such as menus, as noted in this mobile CLS discussion. Dropdowns contribute to that when they alter layout instead of overlaying cleanly.

The fixes are concrete:

  1. Anchor the panel with absolute positioning inside a stable parent.
  2. Animate opacity and transform, not height from zero to auto.
  3. Avoid measuring layout repeatedly inside scroll or resize handlers.
  4. Keep the DOM small if the menu is huge or frequently re-rendered.

If you need a very large custom list, consider whether a searchable combobox is the better pattern. A dropdown menu is not a universal answer. Teams often push one component past the point where it still serves users well.

Adapting for Modern Frameworks and Static Sites

Vanilla JavaScript is still the best place to learn dropdown behavior because frameworks don't remove the underlying rules. They just give you different places to store state and register effects.

In React or Next.js, the open state becomes useState, and the document listener belongs in useEffect with cleanup. In Vue, the same logic usually lives in data, computed, and onMounted or a composable. The hard parts stay the same: sync aria-expanded, return focus on close, and avoid stale event listeners.

The translation is direct

A React version usually looks like this in concept:

  • isOpen controls the class name and hidden state
  • a button click flips isOpen
  • a document-level click handler closes on outside interaction
  • cleanup removes the listener when the component unmounts

A Vue version maps almost one-to-one:

  • ref(false) stores open state
  • @click toggles the menu
  • watch or lifecycle hooks manage outside click listeners
  • template bindings keep ARIA current

For static sites and low-code builds, the same CLS concerns apply — careful absolute positioning and opacity-based transitions keep menus from shifting content and hurting Core Web Vitals scores on simpler stacks.

If you're wiring forms and UI patterns into React, Vue, Next.js, or static site generators, the framework patterns in the Static Forms framework examples are a useful companion reference.

The useful mindset is this: learn the behavior in plain JavaScript first, then map it into your framework. Developers who skip that step often end up with framework-shaped code and browser-level bugs.


If you're building forms on static sites and want the backend side to be as clean as your frontend, Static Forms gives you a fast way to handle submissions without standing up your own server code. It fits especially well when you've already invested in careful form UX, accessible controls, spam protection, and modern framework workflows.