kyle.berry
Writing

The combobox is the boss fight of ARIA

One input, one list, and nearly every hard problem in accessible UI at once. Notes from building an async autocomplete properly.

3 min read
  • accessibility
  • keyboard
  • interaction

If you want to find out whether a component library takes accessibility seriously, skip the buttons and go straight to the autocomplete. The combobox is where ARIA semantics, focus strategy, async state, and race conditions all show up in the same fifty lines. I built one from scratch to make sure I actually understood each piece, and it earned its reputation.

Async combobox

nothing selected yet
component.tsx
'use client'

import { useEffect, useId, useRef, useState } from 'react'

/**
 * An async autocomplete built on the ARIA combobox pattern: debounced
 * queries, a stale-response guard, aria-activedescendant option tracking,
 * and a polite live region announcing result counts.
 */

interface Typeface {
  id: string
  name: string
  classification: string
  year: number
}

const TYPEFACES: Typeface[] = [
  { id: 'akzidenz', name: 'Akzidenz-Grotesk', classification: 'Grotesque', year: 1898 },
  { id: 'futura', name: 'Futura', classification: 'Geometric sans', year: 1927 },
  { id: 'times', name: 'Times New Roman', classification: 'Transitional serif', year: 1932 },
  { id: 'palatino', name: 'Palatino', classification: 'Old-style serif', year: 1949 },
  { id: 'helvetica', name: 'Helvetica', classification: 'Neo-grotesque', year: 1957 },
  { id: 'univers', name: 'Univers', classification: 'Neo-grotesque', year: 1957 },
  { id: 'optima', name: 'Optima', classification: 'Humanist sans', year: 1958 },
  { id: 'eurostile', name: 'Eurostile', classification: 'Square sans', year: 1962 },
  { id: 'sabon', name: 'Sabon', classification: 'Old-style serif', year: 1967 },
  { id: 'avant', name: 'ITC Avant Garde', classification: 'Geometric sans', year: 1970 },
  { id: 'frutiger', name: 'Frutiger', classification: 'Humanist sans', year: 1976 },
  { id: 'bell', name: 'Bell Centennial', classification: 'Utility sans', year: 1978 },
  { id: 'chicago', name: 'Chicago', classification: 'Bitmap sans', year: 1984 },
  { id: 'lucida', name: 'Lucida Grande', classification: 'Humanist sans', year: 1985 },
  { id: 'rotis', name: 'Rotis', classification: 'Semi-serif', year: 1988 },
  { id: 'minion', name: 'Minion', classification: 'Old-style serif', year: 1990 },
  { id: 'georgia', name: 'Georgia', classification: 'Transitional serif', year: 1993 },
  { id: 'verdana', name: 'Verdana', classification: 'Humanist sans', year: 1996 },
  { id: 'gotham', name: 'Gotham', classification: 'Geometric sans', year: 2000 },
  { id: 'source', name: 'Source Sans', classification: 'Humanist sans', year: 2012 },
  { id: 'inter', name: 'Inter', classification: 'Neo-grotesque', year: 2017 },
  { id: 'fraunces', name: 'Fraunces', classification: 'Display serif', year: 2020 },
]

const DEBOUNCE_MS = 200

/** Simulated backend search with realistic latency. */
function searchTypefaces(query: string): Promise<Typeface[]> {
  const q = query.trim().toLowerCase()
  const results = TYPEFACES.filter(
    (t) => t.name.toLowerCase().includes(q) || t.classification.toLowerCase().includes(q),
  )
  const latency = 250 + Math.random() * 450
  return new Promise((resolve) => {
    window.setTimeout(() => resolve(results), latency)
  })
}

export default function AsyncCombobox() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<Typeface[]>([])
  const [open, setOpen] = useState(false)
  const [loading, setLoading] = useState(false)
  const [activeIndex, setActiveIndex] = useState(-1)
  const [selected, setSelected] = useState<Typeface | null>(null)

  const listboxId = useId()
  const inputRef = useRef<HTMLInputElement>(null)
  // Monotonic sequence number: responses that arrive for an older query are dropped.
  const requestSeq = useRef(0)
  const debounceTimer = useRef(0)

  useEffect(() => {
    return () => window.clearTimeout(debounceTimer.current)
  }, [])

  const onQueryChange = (value: string) => {
    setQuery(value)
    window.clearTimeout(debounceTimer.current)
    const seq = ++requestSeq.current

    if (value.trim() === '') {
      setResults([])
      setOpen(false)
      setLoading(false)
      setActiveIndex(-1)
      return
    }

    setLoading(true)
    debounceTimer.current = window.setTimeout(() => {
      void searchTypefaces(value).then((found) => {
        if (seq !== requestSeq.current) return // stale response, a newer query is in flight
        setResults(found)
        setActiveIndex(-1)
        setOpen(true)
        setLoading(false)
      })
    }, DEBOUNCE_MS)
  }

  const select = (face: Typeface) => {
    window.clearTimeout(debounceTimer.current)
    requestSeq.current++ // invalidate any in-flight search so it can't reopen the list
    setSelected(face)
    setQuery('')
    setOpen(false)
    setLoading(false)
    setActiveIndex(-1)
    inputRef.current?.focus()
  }

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowDown') {
      e.preventDefault()
      if (!open && results.length > 0) setOpen(true)
      setActiveIndex((i) => Math.min(i + 1, results.length - 1))
    } else if (e.key === 'ArrowUp') {
      e.preventDefault()
      if (!open && results.length > 0) {
        setOpen(true)
        setActiveIndex(results.length - 1)
        return
      }
      setActiveIndex((i) => Math.max(i - 1, 0))
    } else if (e.key === 'Enter') {
      e.preventDefault()
      const face = results[activeIndex]
      if (open && face) select(face)
    } else if (e.key === 'Escape') {
      e.preventDefault()
      setOpen(false)
      setActiveIndex(-1)
    }
  }

  const activeId =
    activeIndex >= 0 && results[activeIndex]
      ? `${listboxId}-opt-${results[activeIndex].id}`
      : undefined

  return (
    <div
      className="mx-auto w-md max-w-full"
      onBlurCapture={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget)) setOpen(false)
      }}
    >
      <label
        htmlFor={`${listboxId}-input`}
        className="mb-2 block font-mono text-[10px] tracking-[0.14em] text-(--color-fg-subtle) uppercase"
      >
        Find a typeface
      </label>

      <div className="relative">
        <div className="flex items-center gap-3 rounded-(--radius-card) border border-(--color-border-strong) bg-(--color-surface) px-4 py-3">
          <input
            ref={inputRef}
            id={`${listboxId}-input`}
            role="combobox"
            aria-expanded={open}
            aria-autocomplete="list"
            aria-controls={listboxId}
            aria-activedescendant={activeId}
            type="text"
            placeholder="Try “grotesque” or “Frutiger”…"
            value={query}
            onChange={(e) => onQueryChange(e.target.value)}
            onKeyDown={onKeyDown}
            className="w-full bg-transparent text-sm text-(--color-fg) placeholder:text-(--color-fg-subtle) focus:outline-none"
          />
          {loading ? (
            <span
              aria-hidden="true"
              className="size-3.5 shrink-0 animate-spin rounded-full border border-(--color-fg-subtle) border-t-transparent motion-reduce:animate-none"
            />
          ) : null}
        </div>

        <ul
          id={listboxId}
          role="listbox"
          aria-label="Typefaces"
          hidden={!open}
          className="scrollbar-thin absolute inset-x-0 top-full z-10 mt-2 max-h-64 overflow-y-auto rounded-(--radius-card) border border-(--color-border-strong) bg-(--color-surface) py-1"
        >
          {results.length === 0 ? (
            <li className="px-4 py-3 font-mono text-xs text-(--color-fg-subtle)">
              No typefaces match.
            </li>
          ) : (
            results.map((face, i) => (
              <li
                key={face.id}
                id={`${listboxId}-opt-${face.id}`}
                role="option"
                aria-selected={i === activeIndex}
                onMouseEnter={() => setActiveIndex(i)}
                onMouseDown={(e) => e.preventDefault()}
                onClick={() => select(face)}
                className={`flex cursor-default items-baseline justify-between gap-4 px-4 py-2.5 text-sm ${
                  i === activeIndex
                    ? 'bg-(--color-surface-hover) text-(--color-fg)'
                    : 'text-(--color-fg-muted)'
                }`}
              >
                <span className="min-w-0 truncate">{face.name}</span>
                <span className="shrink-0 font-mono text-[10px] whitespace-nowrap text-(--color-fg-subtle)">
                  {face.classification} · {face.year}
                </span>
              </li>
            ))
          )}
        </ul>
      </div>

      {/* Result counts are announced here so screen reader users hear what typing produced. */}
      <div aria-live="polite" className="sr-only">
        {loading
          ? 'Searching…'
          : open
            ? `${results.length} ${results.length === 1 ? 'result' : 'results'} available.`
            : ''}
      </div>

      <div className="mt-4 flex h-8 items-center">
        {selected ? (
          <span className="inline-flex items-center gap-2 rounded-full border border-(--color-border) bg-(--color-surface) py-1 pr-1.5 pl-3 text-sm text-(--color-fg)">
            {selected.name}
            <button
              type="button"
              onClick={() => setSelected(null)}
              aria-label={`Remove ${selected.name}`}
              className="rounded-full px-1.5 font-mono text-xs text-(--color-fg-subtle) transition-colors hover:text-(--color-fg)"
            >

            </button>
          </span>
        ) : (
          <span className="font-mono text-[10px] text-(--color-fg-subtle)">
            nothing selected yet
          </span>
        )}
      </div>
    </div>
  )
}

The contract, in one paragraph

The input takes role="combobox", aria-expanded, and aria-autocomplete="list". The popup is a role="listbox" of role="option" items, tied to the input with aria-controls. The highlighted option is communicated through aria-activedescendant on the input, and aria-selected on the option. That is the whole spec, and each attribute exists because a screen reader asks a specific question: what is this? is it open? what is highlighted right now?

Focus never moves. That is the trick.

The instinct is to treat the list like a menu and move DOM focus into it on ArrowDown. Do that and typing stops working, because the keystrokes now land on a list item. The combobox pattern keeps real focus pinned to the input forever and fakes the highlight with aria-activedescendant, which is exactly what lets you type "gro", arrow down twice, and keep typing. Once I internalized "focus stays, the accessible cursor moves," the rest of the pattern stopped feeling arbitrary.

The pointer side has a matching trap: clicking an option blurs the input, and if your blur handler closes the popup, the click lands on nothing. The fix is one line (onMouseDown={(e) => e.preventDefault()} on the option) and its absence is the classic "why does clicking the dropdown not work" bug.

Async is where implementations die

A static combobox is a filter. A real one talks to a server, and servers answer out of order. Type "grot" and the response for "gro" can land last, quietly replacing better results with stale ones. Worse, aria-activedescendant can now reference an option that no longer exists, which is an ARIA error a sighted developer will never notice.

The demo handles it with a monotonic sequence number: every request takes one, and any response that is not the newest gets dropped. An AbortController works too. What does not work is hoping, which is what most autocompletes ship.

Judge a library by this component

I built this to learn it, not because I think everyone should hand-roll comboboxes. Most of the time you should not. But having built one, I can now read a library's autocomplete in five minutes and know whether the authors did: check for aria-activedescendant, check the mousedown handler, check what happens to in-flight requests. It is a very effective truth serum.

If you want a gentler on-ramp to the same ideas, the command palette in the catalog is this pattern without the async layer: same contract, fewer ways to lose.