---
title: 'Dropdown Lists'
source: 'https://academia.sh/en/courses/accessible-patterns/dropdown-lists'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:50+00:00'
license: 'CC BY-SA 4.0'
---

# Dropdown Lists

Comparing the native selection element with the hand-built combobox; separating the active-option declaration from real focus, testing the keyboard contract, and auditing the invariants of reachable states.

A radio group works when the options fit on screen. The "Subject" field in the catalog's
filter panel carries more than two hundred fifty values; not all of them can be shown on
screen, and even if they were, they could not be navigated. This requires handing the
selection work off to a closable list.

The pattern has two implementations, and the difference between them cannot be
summarized in a single line: one arrives with a single element, the other requires
hand-writing ten separate declarations. This lesson specifies both and counts the cost
of the second.

## The Problem the Pattern Solves

The dropdown list selects a single value from a set of options that is **enumerable but
crowded**. It has two boundaries. At the lower boundary, the list is unnecessary if there
are fewer than five options: four borrow-duration options stand on screen as a radio
group, and the user sees all of them at a glance. At the upper boundary, if the options
come from a set the user does not know in advance — like the author names across the
entire catalog — search is required, not a list.

The list is also not the right component wherever multiple values need to be selected. A
multi-select dropdown pattern does not show the user all of their selections at once; a
checkbox group or a pattern that displays selections separately is used instead.

## The Native Element First

The counterpart is the `select` element. What it brings: the combobox role, a name
computed from the `label` element, an open/closed state declaration, the selected option
declared as the value, grouping with `optgroup`, the entire keyboard contract, and
type-ahead search.

It has a cost, and it has to be written into the specification: the appearance of the
opened option list cannot be controlled from the presentation layer. The list is
constructed outside the page's rendering plane; the typeface, color, row height, and any
extra information placed inside an option are not open to design decisions.

In the catalog's "Subject" field, this cost is affordable. The results-ordering
criterion, borrow-duration, and page-size selectors are all met with the native element.
A hand-built component is chosen only if one of three requirements exists: showing extra
information in the option row (record count, subtitle), filtering by typing, or fetching
options from the server by search.

## The Cost of the Custom Component

The declarations that have to be reproduced in a hand-built component are: role
(`role="combobox"`), open/closed state (`aria-expanded`), the id of the controlled list
(`aria-controls`), the list role (`role="listbox"`), option roles (`role="option"`), the
active option's declaration (`aria-activedescendant`), the selected option's declaration
(`aria-selected`), the autocomplete format (`aria-autocomplete`), the entire keyboard
contract, and announcing the filter result.

```html
<label for="subject">Subject</label>
<input id="subject" role="combobox" aria-expanded="true" aria-controls="subject-list"
       aria-activedescendant="subject-2" aria-autocomplete="list" autocomplete="off">
<ul id="subject-list" role="listbox" aria-label="Subject options">
  <li id="subject-1" role="option" aria-selected="false">Architecture</li>
  <li id="subject-2" role="option" aria-selected="true">Microbiology</li>
</ul>
<p role="status">2 subjects listed</p>
```

The distinctive part of this markup is the `aria-activedescendant` attribute. Keyboard
focus **never leaves the field**; what navigates the list is not focus, it is a
declaration. When the user presses an arrow key, the cursor in the field stays put, and
in the tree, a different option is announced as active. This distinction preserves the
field's text-editing behavior; if focus actually moved to the list, continuing to type
would be impossible.

The distinction's consequence for design is that **two separate indicators** are
required: a focus ring in the field, an active-option highlight in the list. If either is
missing, the user cannot see either where they are or what will be selected.

## Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` | The field is a single tab stop; focus stays in the field even while the list is open. The list closes and focus moves out. |
| `Arrow Down` | Opens the list and activates the first option if closed; moves to the next one if open. |
| `Arrow Up` | Opens the list and activates the last option if closed; moves to the previous one if open. |
| `Enter` | Makes the active option the value and closes the list. |
| `Escape` | Closes the list and keeps the value if open; clears the field if closed. |
| Typing keys | Opens the list and filters the options. |
| `Home` / `End` | Moves the cursor within the text; does not belong to the list. |

The arrow keys **not wrapping** at the ends is the point where this diverges from the
radio group's contract. Wrapping in a radio group does not lose the user, because the
entire group is on screen. Wrapping in a long list throws the user to the other end of
the list with a single keystroke and makes it impossible for them to understand where
they are without reading.

The test below builds the contract as a state machine, checks it with key sequences, and
in the last test scans every reachable state to verify three invariants.

```js
// dropdown.test.mjs — the combobox's keyboard contract and invariants
// Run: node --test dropdown.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";

const SUBJECTS = ["Microeconomics", "Microbiology", "Music History", "Mathematics"];
const lower = (s) => s.toLocaleLowerCase("en-US");

const initial = () => ({ focusInField: true, open: false, active: null, text: "", value: null });
const options = (d) => SUBJECTS.filter((k) => lower(k).startsWith(lower(d.text)));

function key(d, t) {
  const s = options(d);
  if (t === "ArrowDown") {
    if (!d.open) return { ...d, open: true, active: s.length ? 0 : null };
    if (d.active === null) return { ...d, active: s.length ? 0 : null };
    return { ...d, active: Math.min(d.active + 1, s.length - 1) };   // no wraparound
  }
  if (t === "ArrowUp") {
    if (!d.open) return { ...d, open: true, active: s.length ? s.length - 1 : null };
    if (d.active === null) return { ...d, active: s.length ? s.length - 1 : null };
    return { ...d, active: Math.max(d.active - 1, 0) };
  }
  if (t === "Enter") {
    if (d.open && d.active !== null)
      return { ...d, open: false, active: null, text: s[d.active], value: s[d.active] };
    return d;
  }
  if (t === "Escape") {
    if (d.open) return { ...d, open: false, active: null };          // close the list
    return { ...d, text: "", value: null };                          // clear the field
  }
  if (t === "Tab") return { ...d, open: false, active: null, focusInField: false };
  if (t.startsWith("type:")) return { ...d, text: d.text + t.slice(5), open: true, active: null };
  return d;
}

const apply = (d, keys) => keys.reduce(key, d);

test("when closed, ArrowDown opens the list and makes the first option active", () => {
  const d = apply(initial(), ["ArrowDown"]);
  assert.equal(d.open, true);
  assert.equal(options(d)[d.active], "Microeconomics");
});

test("when closed, ArrowUp opens the list and makes the last option active", () => {
  const d = apply(initial(), ["ArrowUp"]);
  assert.equal(options(d)[d.active], "Mathematics");
});

test("ArrowDown does not wrap to the start at the last option", () => {
  const d = apply(initial(), ["ArrowDown", "ArrowDown", "ArrowDown", "ArrowDown", "ArrowDown"]);
  assert.equal(d.active, 3);
  assert.equal(options(d)[d.active], "Mathematics");
});

test("Enter makes the active option the value and closes the list", () => {
  const d = apply(initial(), ["ArrowDown", "ArrowDown", "Enter"]);
  assert.equal(d.value, "Microbiology");
  assert.equal(d.open, false);
  assert.equal(d.active, null);
});

test("typing opens the list and filters it, the active option resets", () => {
  const d = apply(initial(), ["type:M", "type:i"]);
  assert.deepEqual(options(d), ["Microeconomics", "Microbiology"]);
  assert.equal(d.active, null);
});

test("Escape closes the list first, clears the field on the second press", () => {
  const first = apply(initial(), ["type:M", "type:i", "ArrowDown", "Escape"]);
  assert.equal(first.open, false);
  assert.equal(first.text, "Mi");
  const second = apply(first, ["Escape"]);
  assert.equal(second.text, "");
  assert.equal(second.value, null);
});

test("Tab closes the list and takes focus out of the field", () => {
  const d = apply(initial(), ["ArrowDown", "Tab"]);
  assert.equal(d.open, false);
  assert.equal(d.focusInField, false);
});

test("with no match, the list opens but there is no active option", () => {
  const d = apply(initial(), ["type:Z"]);
  assert.deepEqual(options(d), []);
  const e = apply(d, ["ArrowDown"]);
  assert.equal(e.active, null);
});

test("invariants hold across every reachable state, and focus stays in the field", () => {
  const KEYS = ["ArrowDown", "ArrowUp", "Enter", "Escape", "type:M", "type:i", "type:a"];
  const stateKey = (d) => [d.open, d.active, d.text, d.value].join("|");
  const seen = new Map([[stateKey(initial()), initial()]]);
  const queue = [initial()];
  while (queue.length) {
    const d = queue.shift();
    for (const t of KEYS) {
      const y = key(d, t);
      if (y.text.length > 3) continue;               // scan length is capped
      if (seen.has(stateKey(y))) continue;
      seen.set(stateKey(y), y);
      queue.push(y);
    }
  }
  for (const d of seen.values()) {
    assert.equal(d.focusInField, true, "focus left the field");
    if (!d.open) assert.equal(d.active, null, "active option remained in a closed list");
    if (d.active !== null) {
      assert.ok(d.open, "list is closed while an active option exists");
      assert.ok(d.active < options(d).length, "active option is not in the list");
    }
  }
  console.log(`  states scanned: ${seen.size}`);
});
```

```
  states scanned: 90
✔ when closed, ArrowDown opens the list and makes the first option active (0.369792ms)
✔ when closed, ArrowUp opens the list and makes the last option active (0.049834ms)
✔ ArrowDown does not wrap to the start at the last option (0.061833ms)
✔ Enter makes the active option the value and closes the list (0.041791ms)
✔ typing opens the list and filters it, the active option resets (0.286583ms)
✔ Escape closes the list first, clears the field on the second press (0.056916ms)
✔ Tab closes the list and takes focus out of the field (0.042209ms)
✔ with no match, the list opens but there is no active option (0.049791ms)
✔ invariants hold across every reachable state, and focus stays in the field (0.932875ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 35.087417
```

The duration values vary from run to run. The number of states scanned, though, depends
on the typing length being capped at three characters; when the cap is removed, the state
space is infinite, because the user can type as many characters as they want.

## Reading the Invariants

The last test checks three rules, and all three are places where the screen and the tree
can diverge.

**No active option can remain in a closed list.** If the list is closed without clearing
the `aria-activedescendant` value, an option that no longer exists in the tree appears
active. This leads the user to think they are still inside the list after closing it.

**The active option has to be in the list.** When the list shortens after filtering, the
old index becomes invalid. In the test, this is prevented by the `type:` keys resetting
the active option; without the reset, the declaration would point to an id that no longer
exists and would drop silently.

**Focus does not leave the field.** All ninety states satisfy the condition. This follows
from the pattern's definition: an implementation where focus moves to the list is not the
`aria-activedescendant` pattern, it is a different pattern, and in that case the ability
to filter by typing is lost.

## Measurable Constraints

**4.1.2 Name, Role, Value.** The field's name, its combobox role, its open/closed state,
and its selected value have to be programmatically readable. In a hand-built component,
all four are written separately.

**2.4.7 Focus Visible.** Real focus is in the field; its indicator has to be present in
the field. The active-option highlight does not replace this indicator, it is added to
it.

**1.4.11 Non-text Contrast.** The active-option highlight has to separate from
neighboring rows by at least 3:1. A highlight built only with a light gray surface does
not satisfy this measure.

**1.4.13 Content on Hover or Focus.** The opened list is content that appears on focus,
and it has to satisfy three conditions: it has to be dismissible with `Escape`, the
pointer has to be able to move over the list, and it must not disappear without the user
taking an action.

**Announcing the filter result.** How many options remain in a list filtered by typing
is conveyed with a polite live region. The announcement is sent not on every keystroke
but after typing stops; otherwise, the announcement queue prevents the user from hearing
what they type.

## Common Mistake

**Focus moving to the list.** Catching it means trying to type while the list is open: if
the character typed does not go to the field, focus has shifted. In this pattern, the fix
is not moving focus back, it is switching to the `aria-activedescendant` declaration.

**The open/closed state not being updated.** The `aria-expanded` value staying `false`
while the list is visible on screen is the tree contradicting the screen. Catching it is
a uniqueness and consistency check: the list's visibility is compared against the
declared state.

**The native element being abandoned for the sake of appearance.** The hand-built
component's cost is ten declarations, and each is a separate source of error. Catching it
happens at the specification stage: if none of the requirements — extra information in
the option row, filtering by typing, or fetching from the server — exists, the native
element is the decision.

## Summary

- A dropdown list is for option sets that are enumerable but do not fit on screen; a
  radio group is used for fewer than five options, and search is used for sets that are
  not known in advance.
- The `select` element brings the role, name, state, value, and keyboard contract
  together; its cost is that the opened list's appearance cannot be controlled from the
  presentation layer.
- The hand-built component reproduces ten separate declarations; it is chosen only when
  inline extra information, filtering by typing, or fetching from the server is required.
- In the `aria-activedescendant` pattern, focus does not leave the field; what navigates
  the list is a declaration, and this is why two separate visual indicators are required.
- Arrow keys do not wrap at the ends; wrapping behavior belongs only to radio groups whose
  entirety sits on screen.
- No active-option declaration can remain in a closed list, and the active option always
  has to be in the filtered list; resetting the declaration after filtering guarantees
  this.

## Next Step

The four components so far were each taken up on their own: a button, a field, a group, a
list. The borrowing form, by contrast, carries all of them together and raises new
questions. By what measure are fields grouped? When a form is split into three steps, how
is the step change announced, and where does focus go? If submission fails, does the user
lose what they typed? The next lesson specifies the form as a whole and shows where
components that are correct individually can work incorrectly together.
