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.
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
Tweak it2
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:
↑/↓movearia-activedescendantto the previous/next optionHome/Endjump to first/lastEnterorSpaceselects the active optionEscapecloses 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 aboveBuild 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.
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();
});import { useId, useState } from "react";
interface ComboboxProps {
label: string;
options: string[];
onSelect: (value: string) => void;
}
export function Combobox({ label, options, onSelect }: ComboboxProps) {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const inputId = useId();
const listId = useId();
const filtered = options.filter((o) => o.toLowerCase().startsWith(query.toLowerCase()));
function handleKey(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && activeIndex > -1) {
e.preventDefault();
onSelect(filtered[activeIndex]);
setQuery(filtered[activeIndex]);
setIsOpen(false);
} else if (e.key === "Escape") {
setIsOpen(false);
}
}
return (
<div className="combobox">
<label htmlFor={inputId}>{label}</label>
<input
id={inputId}
type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded={isOpen}
aria-controls={listId}
aria-activedescendant={activeIndex > -1 ? `${listId}-opt-${activeIndex}` : undefined}
autoComplete="off"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setActiveIndex(-1);
}}
onKeyDown={handleKey}
onBlur={() => setTimeout(() => setIsOpen(false), 150)}
/>
{isOpen && filtered.length > 0 && (
<ul role="listbox" id={listId}>
{filtered.map((opt, i) => (
<li
key={opt}
id={`${listId}-opt-${i}`}
role="option"
aria-selected={i === activeIndex}
className={i === activeIndex ? "is-active" : ""}
onMouseDown={() => {
onSelect(opt);
setQuery(opt);
setIsOpen(false);
}}
>
{opt}
</li>
))}
</ul>
)}
</div>
);
}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-labelfor categorized lists (the live equivalent ofoptgroup). - 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.
Related
Fieldsets, legends & grouping
Use fieldset and legend to give related controls a shared accessible name — and learn when grouping helps, when it hurts, and the ARIA fallback.
Usability heuristics
A short checklist of interface common sense — status, control, consistency, error recovery — you can audit any screen against in minutes.