Accessibility Developer Kit · Guide 14 / 20

Accessible Select

Native <select> (the preferred answer), styling natively, custom role="listbox"for rich options, and the searchable combobox pattern — Vanilla & React.

  • WCAG: 1.3.1 · 2.1.1 · 4.1.2
  • Level: Intermediate → Advanced
  • Icons: lucide-react (ChevronDown, Check)
1

What we are building

Native first. Custom only when native truly can't deliver.

What we are building

The native <select> gives you keyboard, mobile wheel pickers, type-ahead search, voice-control labels, autofill, and perfect SR support — for free. 90% of the time that is the right answer. Reach for a custom listbox or combobox only when native cannot do what you need (rich option content with descriptions / icons, or type-ahead filtering).

2

The 4 real-world variants

Switch between the inaccessible version and the accessible fix.

The 4 real-world variants

Variant A

Native <select>

Preferred
Live example

Native: full keyboard, mobile wheel picker, autofill, type-ahead search — all for free.

HTML · Accessible
<!-- Native select with optgroup + autocomplete -->
<label for="country">Country</label>
<select id="country" name="country" autocomplete="country" required>
  <option value="">Choose a country</option>

  <optgroup label="South Asia">
    <option value="in">India</option>
    <option value="pk">Pakistan</option>
    <option value="bd">Bangladesh</option>
  </optgroup>

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

The right answer for 90% of use cases. Use optgroup for groups, autocomplete for autofill.

Variant B

Styled native

Preferred
Live example

Still a native <select>. Chevron is a decorative overlay; the real control stays accessible.

CSS · Accessible
/* Style the native select itself — keeps all native behavior */
.select-wrap {
  position: relative;
  display: inline-block;
}
.select-wrap select {
  appearance: none;
  min-height: 44px;
  padding: 8px 36px 8px 12px;
  background: #fff;
  border: 1px solid #cbd5e0;
  border-radius: 6px;
  font: inherit;
  cursor: pointer;
}
.select-wrap select:focus-visible {
  outline: 3px solid #1c6cbf;
  outline-offset: 2px;
}
/* Decorative overlay chevron — real control stays underneath */
.select-wrap::after {
  content: "";
  position: absolute;
  right: 12px; top: 50%;
  transform: translateY(-50%);
  pointer-events: none;
  /* ...chevron shape via SVG background... */
}

Style the native select instead of replacing it. Chevron is a decorative overlay; the real control stays accessible.

Variant C

Custom listbox

When needed
Live example
Plan

Space / ↓ opens. ↑ ↓ moves. Enter selects. Esc closes.

HTML · Accessible
<!-- Button + role="listbox" + role="option" + aria-activedescendant -->
<label id="plan-label">Plan</label>

<button
  type="button"
  class="select-trigger"
  aria-haspopup="listbox"
  aria-expanded="false"
  aria-labelledby="plan-label"
>
  <span>Pro — Up to 25 users</span>
  <svg aria-hidden="true" focusable="false">...</svg>
</button>

<ul
  role="listbox"
  aria-labelledby="plan-label"
  aria-activedescendant="opt-pro"
  tabindex="-1"
  hidden
>
  <li id="opt-free"  role="option" aria-selected="false">Free</li>
  <li id="opt-basic" role="option" aria-selected="false">Basic</li>
  <li id="opt-pro"   role="option" aria-selected="true">Pro</li>
  <li id="opt-team"  role="option" aria-selected="false">Team</li>
</ul>

<!-- JS: Space/ArrowDown open; ArrowUp/Down move highlight;
     Enter selects; Esc closes + return focus to trigger -->

For rich options (icons, descriptions). Button + role=listbox + aria-activedescendant. Keyboard pattern per ARIA Authoring Practices.

Variant D

Searchable combobox

Advanced
Live example

Type to filter. ↑ ↓ navigate. Enter selects. Esc closes.

HTML · Accessible
<!-- ARIA 1.2 combobox pattern -->
<label for="country-cb">Country</label>
<input
  id="country-cb"
  type="text"
  role="combobox"
  aria-expanded="false"
  aria-controls="country-list"
  aria-autocomplete="list"
  aria-activedescendant=""
  autocomplete="off"
>
<ul id="country-list" role="listbox" aria-label="Countries" hidden>
  <li id="opt-0" role="option" aria-selected="true">India</li>
  <li id="opt-1" role="option" aria-selected="false">Indonesia</li>
</ul>

<!-- Live region announces result count to SR users -->
<span role="status" aria-live="polite" class="sr-only">3 results</span>

<!-- JS: filter on input; arrow keys move highlight;
     Enter selects; Esc closes -->

ARIA 1.2 combobox pattern. Type to filter; arrow keys move; Enter selects. Live region announces result count.

3

Flagship implementation

A styled native-select wrapper. Keeps all native behavior.

Flagship implementation

AccessibleSelect.jsx
import React, { useId } from "react";
import "./AccessibleSelect.css";

/**
 * AccessibleSelect — wraps a native <select> with styling.
 *   - label:      visible label (required)
 *   - options:    [{ value, label }] OR grouped
 *   - groups:     [{ label, options: [...] }] (optional)
 *   - value, onChange, name, required, autoComplete
 */
export default function AccessibleSelect({
  label,
  options,
  groups,
  value,
  onChange,
  name,
  required = false,
  autoComplete,
  placeholder = "Choose one",
}) {
  const id = useId();

  return (
    <label className="field" htmlFor={id}>
      <span className="field__label">
        {label}
        {required && <span className="req" aria-hidden="true"> *</span>}
      </span>
      <div className="select-wrap">
        <select
          id={id}
          name={name}
          value={value}
          onChange={onChange}
          required={required}
          autoComplete={autoComplete}
          className="select"
        >
          <option value="">{placeholder}</option>
          {options?.map((o) => (
            <option key={o.value} value={o.value}>{o.label}</option>
          ))}
          {groups?.map((g) => (
            <optgroup key={g.label} label={g.label}>
              {g.options.map((o) => (
                <option key={o.value} value={o.value}>{o.label}</option>
              ))}
            </optgroup>
          ))}
        </select>
      </div>
    </label>
  );
}
Usage · AddressForm.jsx
import { useState } from "react";
import AccessibleSelect from "./AccessibleSelect";

export default function AddressForm() {
  const [country, setCountry] = useState("");

  return (
    <AccessibleSelect
      label="Country"
      name="country"
      required
      autoComplete="country"
      value={country}
      onChange={(e) => setCountry(e.target.value)}
      groups={[
        { label: "South Asia", options: [
          { value: "in", label: "India" },
          { value: "pk", label: "Pakistan" },
          { value: "bd", label: "Bangladesh" },
        ]},
        { label: "Europe", options: [
          { value: "de", label: "Germany" },
          { value: "fr", label: "France" },
          { value: "uk", label: "United Kingdom" },
        ]},
      ]}
    />
  );
}
4

Keyboard behavior

Native select is the gold standard. Custom listboxes must match it.

Keyboard behavior

KeyExpected result (native select)
TabFocuses the select
Space / ↓Opens the dropdown
↑ ↓Moves between options (also changes value on some OSes)
Type a letterJumps to the next option that starts with it
EnterConfirms selection (or submits form if select is closed)
EscCloses without changing value
5

Screen reader expectations

Native select has universal, consistent SR output.

Screen reader expectations

  • Native select focused: “Country, Choose a country, combobox, 1 of 10”
  • Option highlighted: “India, 2 of 10”
  • Optgroup entry: “Europe, group, Germany, 1 of 3”
  • Custom listbox: “Plan, has pop-up, Pro, button” then “listbox, Pro, 3 of 5, selected”
  • Combobox: “Country, combobox, blank, 20 results” (live region) then “India, 1 of 2”
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
1.3.1 Info and Relationships (A)Label must be programmatically associated; optgroup groups options.
2.1.1 Keyboard (A)All actions must be operable with keyboard.
4.1.2 Name, Role, Value (A)Exposes label + role=combobox/listbox + current value.
1.4.11 Non-text Contrast (AA)Select border, chevron, focus ring must have 3:1 contrast.
2.5.5 Target Size (AA, 2.2)Trigger at least 24×24 (prefer 44×44).
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

Pattern: assert accessible name → selectOptions → assert value updated → for custom: assert aria-activedescendant moves on arrow.

Jest + Testing Library + jest-axe

AccessibleSelect.test.jsx
// Jest + @testing-library/react + jest-axe
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import AccessibleSelect from "./AccessibleSelect";

expect.extend(toHaveNoViolations);

function Fixture() {
  const [v, setV] = useState("");
  return (
    <AccessibleSelect
      label="Country"
      value={v}
      onChange={(e) => setV(e.target.value)}
      options={[
        { value: "in", label: "India" },
        { value: "us", label: "United States" },
      ]}
    />
  );
}

test("has an accessible name", () => {
  render(<Fixture />);
  expect(screen.getByRole("combobox", { name: "Country" })).toBeInTheDocument();
});

test("selecting an option updates the value", async () => {
  const user = userEvent.setup();
  render(<Fixture />);
  await user.selectOptions(
    screen.getByRole("combobox", { name: "Country" }),
    "India"
  );
  expect(screen.getByRole("combobox")).toHaveValue("in");
});

test("has no axe violations", async () => {
  const { container } = render(<Fixture />);
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleSelect.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AccessibleSelect from "./AccessibleSelect";

test("exposes optgroup labels", () => {
  function F() {
    const [v, setV] = useState("");
    return (
      <AccessibleSelect
        label="Country"
        value={v}
        onChange={(e) => setV(e.target.value)}
        groups={[{ label: "Asia", options: [{ value: "in", label: "India" }] }]}
      />
    );
  }
  render(<F />);
  expect(screen.getByRole("group", { name: "Asia" })).toBeInTheDocument();
});

Playwright + @axe-core/playwright

select.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("select page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-select");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("custom listbox: ArrowDown + Enter picks an option", async ({ page }) => {
  await page.goto("/en/accessible-select");
  const trigger = page.getByRole("button", { name: /plan/i });
  await trigger.click();
  await page.keyboard.press("ArrowDown");
  await page.keyboard.press("Enter");
  await expect(trigger).toBeFocused();
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Native <select> used unless a specific need forces custom
  • Visible <label for="..."> associated with the select
  • Grouped options use <optgroup label="...">
  • autocomplete set for country/address/cc fields
  • Styled with appearance: none on the native select, not replaced by a div
  • Focus ring and chevron have 3:1 contrast
  • Custom listbox has role=listbox + role=option + aria-activedescendant
  • Custom listbox supports Space/Arrow/Enter/Escape
  • Combobox has role=combobox + aria-expanded + aria-controls + aria-autocomplete
  • Zero critical / serious axe violations
9

Common mistakes

The select bugs we see on nearly every audit.

Common mistakes

  • Hiding native with CSS, overlaying a div. Loses mobile wheel picker, type-ahead, voice control.
  • Custom listbox without aria-activedescendant. SR can’t follow highlight movement.
  • Combobox using role="combobox" on a div. Must be on an input (ARIA 1.2).
  • No label on the select. SR says “combobox, blank” with no context.
  • Low-contrast chevron. Fails 1.4.11.
  • Reaching for a custom select when native would do. Over-engineered, more bugs, worse UX on mobile.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo