HTML dropdown menu

A form dropdown and a navigation dropdown are different components with different markup. Picking the wrong one is the usual reason the menu does not work on a phone.

An HTML dropdown menu is one of two different components, and the word covers both. If the reader is picking a value, you want <select>. If the reader is going somewhere, you want a button that reveals a list of links.

Choosing the wrong one is the most common cause of a menu that looks right on a laptop and cannot be used on a phone.

A native select control expanded, with the option list drawn by the operating system.
A native select control expanded, with the option list drawn by the operating system.

Two things called an HTML dropdown menu

You want the reader to Component Styling of the list
Pick one value to submit <select> Controlled by the system
Pick one of two or three Radio buttons Fully yours
Navigate to another page Button plus list of links Fully yours
Run a command Button plus list of buttons Fully yours
Filter what is shown <select> plus a listener Controlled by the system

The third and fourth rows are the ones people call a menu. The first and last are what most tutorials show, which is why searches for this phrase return two unrelated answers.

The form dropdown

<label for="plan">Plan</label>
<select id="plan" name="plan">
  <option value="">Choose one</option>
  <option value="team">Team</option>
  <option value="org" selected>Organisation</option>
</select>

Three things this gives you for free: keyboard navigation with the arrow keys, type-ahead when the reader starts typing an option name, and the native picker on a phone.

Three things it does not give you: styled options, icons in the list, and multiple columns. The option list is rendered by the operating system. No CSS reaches inside it.

If the design needs those, you are building a menu, not using a select. Accept the extra work rather than styling a div to look like one.

Grouping long lists is worth doing:

<optgroup label="Europe">
  <option value="de">Germany</option>
  <option value="fr">France</option>
</optgroup>

Setting which option starts chosen has its own rules, covered in default selected option.

The navigation dropdown

<div class="menu">
  <button type="button" aria-expanded="false" aria-controls="m1">Products</button>
  <ul id="m1" hidden>
    <li><a href="/reports">Reports</a></li>
    <li><a href="/dashboards">Dashboards</a></li>
    <li><a href="/invoices">Invoices</a></li>
  </ul>
</div>
.menu { position: relative; }
.menu ul { position: absolute; top: 100%; left: 0; margin: 0; padding: .25rem;
           list-style: none; background: #16181d; border: 1px solid #2c2f36; min-width: 12rem; }
var b = document.querySelector('.menu button');
var m = document.getElementById('m1');
b.addEventListener('click', function () {
  var open = b.getAttribute('aria-expanded') === 'true';
  b.setAttribute('aria-expanded', String(!open));
  m.hidden = open;
});
document.addEventListener('keydown', function (e) {
  if (e.key === 'Escape' && !m.hidden) { m.hidden = true; b.setAttribute('aria-expanded', 'false'); b.focus(); }
});
A navigation dropdown open under its trigger button, showing three links.
A navigation dropdown open under its trigger button, showing three links.

Why hover menus fail

The classic CSS menu shows the list on :hover. It needs no script, and it has two problems that no amount of CSS fixes.

Phones and tablets have no hover state. A tap is synthesised as a hover in some browsers and not others, so the menu opens on the first tap sometimes and never opens other times.

Keyboard users never hover at all. Adding :focus-within helps, but the menu still cannot be dismissed without moving focus elsewhere.

A click trigger handles pointer, touch and keyboard with one code path. That is the reason to write the eight lines.

There is a third problem that only shows up in use. A hover menu opens when the pointer crosses it on the way somewhere else, so the reader gets a panel they did not ask for covering what they were reading.

Delays are the usual patch. They make the menu feel sluggish for the people who did want it, which trades one complaint for another.

The four rules a menu has to keep

  1. The trigger is a <button>. A div is not focusable and is not announced as anything. A link with href="#" jumps the page.
  2. aria-expanded matches reality. Set it on every state change, not only on open.
  3. Escape closes it and focus returns to the trigger. Otherwise the keyboard user is stranded after the last item.
  4. Clicking outside closes it. Bind on document and check whether the click landed inside the menu.

Labelling controls covers what to do when the trigger is an icon with no visible text.

Arrow key navigation inside the menu

A select gives you arrow keys. A button and list menu has to implement them, and it is about ten lines.

var items = m.querySelectorAll('a');
m.addEventListener('keydown', function (e) {
  var i = Array.prototype.indexOf.call(items, document.activeElement);
  if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length].focus(); }
  if (e.key === 'ArrowUp')   { e.preventDefault(); items[(i - 1 + items.length) % items.length].focus(); }
  if (e.key === 'Home')      { e.preventDefault(); items[0].focus(); }
  if (e.key === 'End')       { e.preventDefault(); items[items.length - 1].focus(); }
});

The preventDefault calls stop the page scrolling while the reader moves through the list, which is the part that makes an otherwise working menu feel wrong.

Focus the first item when the menu opens with the keyboard, and leave focus on the trigger when it opens with a click. That difference is small and it is what separates a menu that feels native from one that does not.

A select that navigates

A pattern worth naming so you can avoid it: a <select> whose change event sends the reader to another page.

It is compact and it breaks keyboard use, because arrowing through the options navigates away on every step before the reader reaches the one they wanted. If you must use it, act on a separate Go button rather than on change.

Positioning without a library

position: absolute on the list and position: relative on the wrapper puts the list under the button. Two adjustments handle the edges.

For a menu near the right edge, swap left: 0 for right: 0 so it opens inward. For a menu inside a scrolling container, the absolute list is clipped by overflow: hidden on the ancestor, so the ancestor needs overflow: visible.

Flexbox handles the row of triggers along the bar itself, and building the bar covers the rest of that layout.

Checking it

The menu opened with the keyboard, with focus visible on the first item.
The menu opened with the keyboard, with focus visible on the first item.

Open the page in the HTML file opener and use it without touching the mouse. Tab to the trigger, press Enter, arrow to an item, press Escape.

Then narrow the window to phone width and do it with taps only. Those two passes catch nearly everything.

When it works, paste the HTML into a NOS document. The menu renders and runs at the document's own address, so you send a link that opens on a phone rather than a file that does not.

Questions people ask

What is the HTML tag for a dropdown menu?

For a form control it is select, with option elements inside it. For a navigation menu there is no single tag. You build it from a button and a list, and show the list when the button is activated.

Why does my hover dropdown not work on a phone?

Phones have no hover state. The first tap is treated as a hover and often opens the menu, and the second tap follows the link, but the behaviour varies and some menus become unreachable. Use a click or focus trigger instead.

Can I style the options inside a select?

Only slightly, and it varies by operating system. The list of options is drawn by the system, not by your CSS. If the design requires styled options, you need a button and list menu rather than a select.

How do I make a dropdown menu keyboard accessible?

Use a real button as the trigger, set aria-expanded to reflect the state, put the items in a list of links, close the menu on Escape, and return focus to the button when it closes. A select gets all of this from the browser.

Keep reading