# Custom dropdowns that a keyboard can't open or read

Source: https://easeweb.dev/learn/custom-select
Topics: Custom inputs > Custom dropdowns work like a native select (WCAG 2.1.1, 4.1.2, 1.3.1)
The fix: Either use a native select with a label and style its closed box, which the browser makes reachable, operable and announced on every device. Or, when the options need content a select can't hold, build the ARIA select-only combobox: a focusable element with role="combobox", a name and aria-expanded, a listbox of options, aria-activedescendant moved by the arrow keys, and Enter, Space, Escape and Tab handled as a select handles them.
Test with: keyboard, screen reader, zoom, forced colors
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/select https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Customizable_select

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A shop's product page has a Size dropdown above the Add to basket button. It shows "Choose a size" in a rounded box with a chevron, and a click opens a list: Small, Medium, Large, Extra large. Someone using a keyboard meets one of two builds. In the first, Tab jumps straight from the product name to Add to basket: the dropdown never takes focus, so no size can be chosen, and the basket refuses the item. In the second, the dropdown does take focus, Enter opens it, and the arrow keys move a highlight through the sizes. A screen reader, though, says only "Choose a size", with no role and no hint that it opens, reads nothing as the highlight moves, and after Enter still says nothing about which size was chosen.

## Why it fails

Success Criterion 2.1.1 asks that everything a mouse can do can be done with a keyboard. In the first build, the trigger is a `div` with a click handler: it can't take focus and no key reaches it or its options. Success Criterion 4.1.2 asks that a control's name, role and value, and any state the user can change, are available to assistive technology. In the second build, the keyboard works, but the element has no role, its name is whatever text it shows, the open list is not exposed as open, and the highlight is a CSS class. The browser has nothing to pass on, so a screen reader user can operate the control only by guessing.

## Who is affected

Dropdowns are often the step that must be done before anything else, such as a size, a delivery country or a time slot, so a dropdown that can't be used stops the whole task. On a phone, a native select opens the system picker with large rows; a rebuilt one gives up that picker too.

## What automation and AI miss

A scan sees a `div` with some text and a `ul` of `li` elements. With no role or ARIA, nothing in the markup breaks a rule a scanner knows, and the list is hidden until a click. A scan that checks only for `tabindex` passes the second build. AI assistants asked for "a styled dropdown" often write exactly these two builds, or add `role="listbox"` to the list but leave the trigger without a role, value or `aria-activedescendant`, which still leaves the highlight silent. Only tabbing, opening, arrowing and listening shows whether it works.

## Before: a dropdown only a click can open

```html
<span id="size-label">Size</span>
<div class="select">
  <div class="select-trigger">Choose a size</div>
  <ul class="select-options" hidden>
    <li>Small</li>
    <li>Medium</li>
    <li>Large</li>
    <li>Extra large</li>
  </ul>
</div>
```

```js
trigger.addEventListener('click', () => { list.hidden = !list.hidden; });
list.addEventListener('click', (event) => {
  trigger.textContent = event.target.textContent;
  list.hidden = true;
});
```

The trigger and the options are a `div` and list items with click handlers. None of them can take focus, so Tab skips the dropdown and no key opens it.

## Before: a highlight only the eye can see

```html
<span id="size-label">Size</span>
<div class="select" tabindex="0">
  <div class="select-trigger">Choose a size</div>
  <ul class="select-options" hidden>…</ul>
</div>
```

```js
let active = -1;
select.addEventListener('keydown', (event) => {
  const items = [...list.children];
  if ((event.key === 'Enter' || event.key === ' ') && list.hidden) list.hidden = false;
  else if (event.key === 'ArrowDown') active = Math.min(active + 1, items.length - 1);
  else if (event.key === 'ArrowUp') active = Math.max(active - 1, 0);
  else if (event.key === 'Enter' && active > -1) {
    trigger.textContent = items[active].textContent;
    list.hidden = true;
  } else return;
  event.preventDefault();
  items.forEach((li, i) => li.classList.toggle('active', i === active));
});
```

This is the same dropdown with `tabindex` and keys added. Tab reaches it and the keys work, but it has no role, its value is just text, the open list isn't exposed, and the highlight is a class, so a screen reader user hears "Choose a size" and then nothing.

## After: a native select with a styled box

```html
<label for="size">Size</label>
<span class="select-box">
  <select id="size" name="size">
    <option value="">Choose a size</option>
    <option value="s">Small</option>
    <option value="m">Medium</option>
    <option value="l">Large</option>
    <option value="xl">Extra large</option>
  </select>
</span>
```

```css
.select-box {
  position: relative;
  display: inline-block;
}
.select-box select {
  appearance: none;
  font: inherit;
  padding: 0.5rem 2.5rem 0.5rem 0.75rem;
  border: 1px solid var(--line-strong);
  border-radius: 0.5rem;
  background: var(--page);
  color: var(--ink);
}
/* The chevron is drawn in currentColor, so it follows the text in forced colors. */
.select-box::after {
  content: "";
  position: absolute;
  right: 0.9rem;
  top: 45%;
  width: 0.45rem;
  height: 0.45rem;
  border: solid currentColor;
  border-width: 0 2px 2px 0;
  transform: translateY(-50%) rotate(45deg);
  pointer-events: none;
}
.select-box select:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
```

The browser makes the select a Tab stop, opens it with the keys each platform expects, moves through the options with the arrows and by typing a letter, and tells a screen reader its name, its role, its value and each option. On a phone it opens the system picker. Only the closed box is restyled: the border, the chevron and the focus ring are yours to draw, and all three survive forced colors.

## After: an ARIA select-only combobox, when a select can't hold the options

```html
<span id="size-label">Size</span>
<div id="size" class="select-trigger" role="combobox" tabindex="0"
  aria-labelledby="size-label" aria-haspopup="listbox"
  aria-expanded="false" aria-controls="size-list">Choose a size</div>
<ul id="size-list" class="select-options" role="listbox" aria-labelledby="size-label" hidden>
  <li id="size-s" role="option" aria-selected="false">Small</li>
  <li id="size-m" role="option" aria-selected="false">Medium</li>
  <li id="size-l" role="option" aria-selected="false">Large</li>
  <li id="size-xl" role="option" aria-selected="false">Extra large</li>
</ul>
<input type="hidden" name="size" id="size-value">
```

```js
const options = [...list.children];
let active = -1;
function move(index) {
  active = Math.max(0, Math.min(index, options.length - 1));
  options.forEach((li, i) => li.setAttribute('aria-selected', String(i === active)));
  trigger.setAttribute('aria-activedescendant', options[active].id);
}
function chosen() {
  return options.findIndex((li) => li.id === `size-${field.value}`);
}
function open() {
  list.hidden = false;
  trigger.setAttribute('aria-expanded', 'true');
  move(Math.max(chosen(), 0));
}
function close() {
  list.hidden = true;
  trigger.setAttribute('aria-expanded', 'false');
  trigger.removeAttribute('aria-activedescendant');
}
function pick(index) {
  field.value = options[index].id.replace('size-', '');
  trigger.textContent = options[index].textContent;
  close();
}
trigger.addEventListener('keydown', (event) => {
  const shut = list.hidden;
  if (shut && ['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(event.key)) open();
  else if (!shut && event.key === 'ArrowDown') move(active + 1);
  else if (!shut && event.key === 'ArrowUp') move(active - 1);
  else if (!shut && event.key === 'Home') move(0);
  else if (!shut && event.key === 'End') move(options.length - 1);
  else if (!shut && (event.key === 'Enter' || event.key === ' ')) pick(active);
  else if (!shut && event.key === 'Escape') close();
  else if (!shut && event.key === 'Tab') return pick(active);
  else if (event.key.length === 1) {
    // Typing a letter jumps to the next option starting with it, as in a select.
    const letter = event.key.toLowerCase();
    const starts = (li) => li.textContent.toLowerCase().startsWith(letter);
    const next = options.findIndex((li, i) => i > active && starts(li));
    const first = options.findIndex(starts);
    if (first < 0) return;
    if (shut) open();
    move(next > -1 ? next : first);
  } else return;
  event.preventDefault();
});
trigger.addEventListener('click', () => (list.hidden ? open() : close()));
// Keep focus on the trigger, so a click picks before the trigger's blur closes the list.
list.addEventListener('mousedown', (event) => event.preventDefault());
list.addEventListener('click', (event) => {
  const li = event.target.closest('[role="option"]');
  if (li) pick(options.indexOf(li));
});
trigger.addEventListener('blur', close);
```

```css
/* The highlight comes from the state a screen reader reads, never from a class of its own. */
.select-options [aria-selected="true"] {
  background: var(--highlight);
  outline: 2px solid transparent;
}
@media (forced-colors: active) {
  .select-options [aria-selected="true"] {
    outline-color: Highlight;
  }
}
```

The trigger is a Tab stop announced as a combo box with its name, its current text as the value, and whether its list is open. Focus never leaves it: the arrow keys, Home, End and a typed letter move `aria-activedescendant`, so each option is read as the highlight reaches it. Enter or Space picks, Escape closes without changing the value, Tab picks and moves on as a select does, and a click picks the same way. The hidden input carries the value into the form.

This passes only as far as your script does. It has to handle every key above, keep `aria-expanded`, `aria-activedescendant` and `aria-selected` in step, and stay usable on a phone, where it opens your list rather than the system picker. Test it in every browser and screen reader you support.

## Which option to use

Both pass 2.1.1 and 4.1.2. Use the native select: it does the same job with no script, and the browser gives every device its own keys, its own picker and its own announcements, which no rebuild matches. Build the ARIA combobox only when an option has to show something a select can't, such as a colour swatch or a stock note beside the size, and accept that its keys, states and touch behaviour are yours to test and maintain.

## Implementation decisions

Styling only the closed box covers most designs: the open list is drawn by the system, which suits phones and screen magnifiers. Newer Chromium browsers also let the open list be styled with `appearance: base-select`, so a native select can show richer options while other browsers fall back to the ordinary select. Treat that as an enhancement, and test the fallback.

Keep a visible label beside the dropdown. A placeholder option such as "Choose a size" is the value until something is picked, not the name; with a select, give it `value=""` and make the field `required` if a size must be chosen.

Hiding a native select and drawing a rebuilt one over it, as some older jQuery plugins do, gives you both problems: the real select is out of reach, and the drawn one is one of the Befores. Check component libraries by tabbing, opening, arrowing and listening, as in the steps below, before trusting their documentation. Choosing an option must not submit the form or load another page by itself; see [dropdowns and fields that change the page on their own](/learn/context-changes). When people type to filter a long list instead, see [autocomplete fields with silent, mouse-only suggestions](/learn/combobox).

## Verify the fix

1. Inspect the dropdown: it is a native select with a label, or has role="combobox", a name, aria-expanded and aria-controls naming a listbox of options.
2. Put the mouse aside and Tab to the dropdown, then open it with Enter, Space or the down arrow, as your platform's select opens.
3. Move through the options with the arrows and by typing a letter, pick one with Enter, and try Escape, a mouse click and a tap: each picks or closes as expected and leaves focus on the dropdown.
4. With a screen reader, Tab to the dropdown and choose a size: you hear its name, its role and its value, each option as you reach it, and the new value after picking.
5. At 200% zoom and in forced colors, check the closed dropdown still looks like a control, and the highlighted option is visible.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the shared dropdown component changes.

## Limits

This is a starting pattern for a single-choice dropdown. It doesn't cover choosing several options, grouped options, or a list loaded from a server as the person scrolls. Test the complete journey, with your real options, in the browsers and assistive technology you support.
