Skip to content
Accessibility & inclusive designLesson 1 of 7Intermediate18 min

The dropdown taxonomy

Every dropdown is really one of ten patterns — native select, listbox, combobox, multiselect, and more. Learn each ARIA contract and when to reach for it.

Kept on this device only.

In one line: "dropdown" names a behavior, not a control — it's really ten distinct patterns, and picking the wrong one costs conversions, keyboard users, and screen-reader users at once.

What it is

The word "dropdown" describes a visual behavior — a hidden list that appears on interaction — not a single HTML or ARIA construct. The family includes the native select element, custom listboxes, comboboxes, multiselects, autocomplete fields, segmented controls, cascading selects, and tree selects. Each has different semantics, a different keyboard contract, and different accessibility requirements.

Why it matters

Pattern mismatch is one of the most common form-usability failures. A searchable combobox over a 3-item list adds friction for nothing. A bare native select over 300 countries forces endless scrolling. The right choice comes down to two variables: how many options exist, and whether all options should always be visible.

See it

Live demo
Tweak it2
8
Slide the option count and toggle multi-select; the recommended pattern renders live.

Move the option-count slider and toggle single vs. multi select; the demo names the pattern the decision tree recommends and renders a working, keyboard-operable instance of it.

How it works

The ten patterns

1. Native select — the browser's own element. Zero custom styling overhead, and keyboard support (arrows, type-ahead, Enter) is free and reliable. The right default for 5–7 options.

<label for="fruit">Fruit</label>
<select id="fruit" name="fruit">
  <option value="">Choose a fruit</option>
  <option value="apple">Apple</option>
  <option value="mango">Mango</option>
  <option value="lychee">Lychee</option>
</select>

2. Styled select wrapper — wrap the native element to attach a chevron, border, and background, with appearance: none on the control. Semantics are preserved; the open list still renders the OS picker on most platforms.

3. Custom listbox (role="listbox") — full visual control, but you own the entire keyboard contract:

  • / move aria-activedescendant to the previous/next option
  • Home/End jump to first/last
  • Enter or Space selects the active option
  • Escape closes and returns focus to the trigger
  • Type-ahead jumps to the first option starting with the typed character
<button id="fruit-btn" aria-haspopup="listbox" aria-expanded="false">
  <span id="fruit-label">Fruit</span>
  <span>Choose a fruit</span>
</button>
<ul role="listbox" id="fruit-list" aria-labelledby="fruit-label" tabindex="-1">
  <li role="option" id="opt-apple" aria-selected="false">Apple</li>
  <li role="option" id="opt-mango" aria-selected="false">Mango</li>
</ul>

4. Combobox (role="combobox") — a text input paired with a listbox; type to filter. Appropriate for 15–200+ items, single-select. The role="combobox" goes on the input, aria-expanded reflects listbox visibility, and aria-controls points to the listbox.

<label for="country-input">Country</label>
<input
  type="text"
  id="country-input"
  role="combobox"
  aria-autocomplete="list"
  aria-expanded="false"
  aria-controls="country-list"
  aria-activedescendant=""
  autocomplete="off"
/>
<ul role="listbox" id="country-list" hidden></ul>

5. Multiselect combobox — a combobox that accepts multiple picks, rendered as removable chips. Each chip needs a button with a descriptive label (aria-label="Remove Mango"); the input filters the remaining options.

6. Autocomplete / predictive search — a combobox backed by server-side or fuzzy-matched suggestions when the full set isn't pre-loaded. Debounce the request (~300–400 ms), set aria-busy while loading, and announce result counts with an aria-live="polite" region.

7. Segmented control / button group — 2–4 mutually exclusive choices where all options stay visible. Not a dropdown at all: adjacent buttons with aria-pressed, or styled radios. Good for density ("Compact / Comfortable") and view mode ("Grid / List").

8. Cascading / dependent selects — a parent selection changes child options (Country → State). Never remove options that become unavailable — disable them, and announce the change with aria-live="polite".

9. Tree select — hierarchical selection for genuinely tree-shaped data (taxonomy, org chart). Rare. Uses role="tree"/role="treeitem" with aria-expanded. A two-level hierarchy is better served by grouped optgroup elements.

10. Date picker — structured date input. Native <input type="date"> is well supported and should be the first choice; reach for a custom picker only for ranges, calendar context, timezones, or relative dates.

The decision guide

How many options?
├── ≤ 4   → Segmented control or radio buttons (always visible)
├── 5–7   → Native select
├── 8–15  → Native or styled select
├── 15–50 → Combobox with inline search
└── 50+   → Combobox with debounced or server-side search
 
Multiple selections?
├── Yes + few options  → Checkbox group
├── Yes + many options → Multiselect combobox with chips
└── No                 → see above

Build it

The combobox is the pattern people get wrong most often, so here it is twice — once in vanilla TypeScript, once in React. Both keep focus on the input and move only aria-activedescendant.

Accessible combobox — vanilla TypeScript
const input = document.querySelector("#country-input") as HTMLInputElement;
const listbox = document.querySelector("#country-list") as HTMLUListElement;
const allOptions = Array.from(listbox.querySelectorAll<HTMLElement>('[role="option"]'));
let activeIndex = -1;
 
function setActive(index: number) {
  allOptions.forEach((opt, i) => {
    const isActive = i === index;
    opt.classList.toggle("is-active", isActive);
    opt.setAttribute("aria-selected", String(isActive));
    if (isActive) {
      input.setAttribute("aria-activedescendant", opt.id);
      opt.scrollIntoView({ block: "nearest" });
    }
  });
  activeIndex = index;
}
 
function openListbox() {
  listbox.hidden = false;
  input.setAttribute("aria-expanded", "true");
}
function closeListbox() {
  listbox.hidden = true;
  input.setAttribute("aria-expanded", "false");
  input.setAttribute("aria-activedescendant", "");
  activeIndex = -1;
}
 
input.addEventListener("input", () => {
  const query = input.value.toLowerCase();
  let visible = 0;
  allOptions.forEach((opt) => {
    const matches = (opt.textContent ?? "").toLowerCase().startsWith(query);
    opt.hidden = !matches;
    if (matches) visible++;
  });
  if (visible > 0) openListbox();
  else closeListbox();
});
 
input.addEventListener("keydown", (e) => {
  const visible = allOptions.filter((o) => !o.hidden);
  if (e.key === "ArrowDown") {
    e.preventDefault();
    const next = Math.min(activeIndex + 1, visible.length - 1);
    setActive(allOptions.indexOf(visible[next]));
  } else if (e.key === "ArrowUp") {
    e.preventDefault();
    const prev = Math.max(activeIndex - 1, 0);
    setActive(allOptions.indexOf(visible[prev]));
  } else if (e.key === "Enter" && activeIndex > -1) {
    e.preventDefault();
    input.value = allOptions[activeIndex].textContent ?? "";
    closeListbox();
  } else if (e.key === "Escape") {
    closeListbox();
  }
});
 
document.addEventListener("click", (e) => {
  if (!input.contains(e.target as Node) && !listbox.contains(e.target as Node)) closeListbox();
});

Make it yours

Use the controls beside the demo above to change option count and allow multiple selections — each change updates the example live.

Experiment in the playground
  • Add "Select all" / "Clear all" to multiselect comboboxes with seven or more options.
  • Group options with role="group" + aria-label for categorized lists (the live equivalent of optgroup).
  • For country/state lists, include the ISO code in the visible text — "United States (US)" is then searchable by "US".
  • Expose all options on first focus, before the user types — people who don't know what to search for need to browse.

Reproduce it with an LLM

Reproduce it with an LLM

You are a senior front-end engineer. Build an accessible combobox for selecting a Lumen product category. It filters a list of 40 options as the user types, supports full keyboard navigation (Arrow keys, Enter, Escape), tracks aria-activedescendant on the input (focus never leaves the input), announces result-count changes with an aria-live region, and respects prefers-reduced-motion on the open/close transition. Use vanilla TypeScript with no framework dependencies. Return only the component code with imports.

Pitfalls & accessibility

  • Test on mobile. Custom comboboxes behave differently under iOS VoiceOver than desktop NVDA/JAWS; at minimum verify the input is reachable, options are navigable, and a selection can be confirmed.

Further reading