# Keep Focus in the Input With aria-activedescendant

**SHOULD** · **ID:** `interactions-aria-activedescendant` · **Category:** interactions
**Source:** [ARIA](https://www.w3.org/WAI/ARIA/apg/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-activedescendant

> SHOULD: Default to roving `tabindex` (real DOM focus buys you scroll-into-view, the focus ring and focus events); reach for `aria-activedescendant` only where DOM focus must STAY in a text input — combobox, editable grid cell, tag input — because the moment focus leaves the `<input>` keystrokes stop landing, the caret vanishes, an IME composition dies mid-word and the mobile keyboard drops. Its costs are yours: stable `id` per option, style the active option yourself (it has no `:focus`), and `scrollIntoView({ block: "nearest" })` by hand.

Point at the active option instead of moving focus whenever the user must keep typing

These are the two focus-management techniques for a composite, and they are not interchangeable. Roving tabindex moves REAL DOM focus onto the active child, which is why it is the default: the browser scrolls that child into view, paints the focus ring, and fires focus events, all for free. But it has one fatal domain — anywhere the user must keep typing. In a combobox, an editable grid cell, or a tag input, ArrowDown must move the highlight WITHOUT moving focus, because the moment focus leaves the <input> the keystrokes stop landing, the caret disappears, an IME composition is destroyed mid-word, and on mobile the software keyboard drops away. That is what aria-activedescendant is for: DOM focus stays on the container (the input), and the attribute holds the id of the active descendant, so assistive technology announces the option while the text field keeps receiving keys. The costs are real and yours to pay: every option needs a stable id, the container needs aria-activedescendant updated on every move, you must style the active option yourself because it has no :focus, and you must scroll it into view yourself with scrollIntoView({ block: "nearest" }) — the free ride roving tabindex got from the browser is exactly what you are giving up.

## Rule snippet

```tsx
<input role="combobox" aria-controls="lb" aria-activedescendant={activeId} />
<ul id="lb" role="listbox"><li id="opt-2" role="option" aria-selected /></ul>
```

## Bad — do not do this

`interactions-aria-activedescendant-bad`

```tsx
import { useEffect, useRef, useState } from 'react';

const CITIES = ['Berlin', 'Bergen', 'Belgrade', 'Bristol', 'Brno'];

function describeActiveElement(): string {
  const el = document.activeElement as HTMLElement | null;
  if (!el || el === document.body) return 'body — nothing focused';
  const tag = el.tagName.toLowerCase();
  const role = el.getAttribute('role');
  if (tag === 'input') return '<input> — typing goes here';
  const text = el.textContent?.trim().slice(0, 24);
  return `<${tag}${role ? ` role="${role}"` : ''}>${text ? ` "${text}"` : ''}`;
}

export function AriaActivedescendantBad() {
  const [query, setQuery] = useState('B');
  const [activeElement, setActiveElement] = useState('body — nothing focused');
  const optionRefs = useRef<(HTMLDivElement | null)[]>([]);

  const matches = CITIES.filter((c) =>
    c.toLowerCase().startsWith(query.toLowerCase())
  );

  useEffect(() => {
    const sync = () => setActiveElement(describeActiveElement());
    document.addEventListener('focusin', sync);
    document.addEventListener('focusout', sync);
    document.addEventListener('keyup', sync);
    return () => {
      document.removeEventListener('focusin', sync);
      document.removeEventListener('focusout', sync);
      document.removeEventListener('keyup', sync);
    };
  }, []);

  return (
    <div className="w-full max-w-sm">
      <div className="rounded-lg border border-border bg-card p-3">
        <label className="mb-1 block text-xs text-muted-foreground" htmlFor="city-bad">
          City
        </label>
        <input
          id="city-bad"
          role="combobox"
          aria-expanded="true"
          aria-controls="city-bad-list"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={(e) => {
            if (e.key !== 'ArrowDown') return;
            e.preventDefault();
            e.stopPropagation();
            // Real DOM focus is yanked out of the text field. The input is no
            // longer the active element, so it no longer receives keystrokes.
            optionRefs.current[0]?.focus();
          }}
          className="w-full rounded border border-input bg-background px-2 py-1 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        />

        <div
          id="city-bad-list"
          role="listbox"
          aria-label="Cities"
          className="mt-2 space-y-0.5"
        >
          {matches.map((city, index) => (
            <div
              key={city}
              ref={(el) => {
                optionRefs.current[index] = el;
              }}
              role="option"
              aria-selected="false"
              tabIndex={0}
              onKeyDown={(e) => {
                if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
                e.preventDefault();
                e.stopPropagation();
                const next = index + (e.key === 'ArrowDown' ? 1 : -1);
                optionRefs.current[Math.min(Math.max(next, 0), matches.length - 1)]?.focus();
              }}
              className="cursor-pointer rounded px-2 py-1 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              {city}
            </div>
          ))}
          {matches.length === 0 && (
            <p className="px-2 py-1 text-sm text-muted-foreground">No matches</p>
          )}
        </div>
      </div>

      <p className="mt-3 rounded border border-border bg-muted px-3 py-2 text-xs text-foreground">
        <span className="text-muted-foreground">document.activeElement:</span>{' '}
        <span className="font-medium text-destructive">{activeElement}</span>
        <br />
        <span className="text-muted-foreground">Input value:</span>{' '}
        <span className="font-medium">{query || '(empty)'}</span>
      </p>

      <p className="mt-3 text-xs text-muted-foreground">
        Click the field, press ArrowDown once, then keep typing letters. The readout
        shows focus has left the input — so nothing you type lands, the value is frozen,
        and there is no way back to refining the query. On a phone the software keyboard
        drops away; with an IME, composition is destroyed mid-word.
      </p>

      <p className="mt-2 text-xs text-destructive">
        Moving real DOM focus out of the text field breaks the thing the field is for
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-aria-activedescendant-good`

```tsx
import { useEffect, useState } from 'react';

const CITIES = ['Berlin', 'Bergen', 'Belgrade', 'Bristol', 'Brno'];

function describeActiveElement(): string {
  const el = document.activeElement as HTMLElement | null;
  if (!el || el === document.body) return 'body — nothing focused';
  const tag = el.tagName.toLowerCase();
  const role = el.getAttribute('role');
  if (tag === 'input') return '<input> — typing goes here';
  const text = el.textContent?.trim().slice(0, 24);
  return `<${tag}${role ? ` role="${role}"` : ''}>${text ? ` "${text}"` : ''}`;
}

export function AriaActivedescendantGood() {
  const [query, setQuery] = useState('B');
  const [active, setActive] = useState(0);
  const [activeElement, setActiveElement] = useState('body — nothing focused');

  const matches = CITIES.filter((c) =>
    c.toLowerCase().startsWith(query.toLowerCase())
  );
  const activeIndex = Math.min(active, Math.max(matches.length - 1, 0));

  useEffect(() => {
    const sync = () => setActiveElement(describeActiveElement());
    document.addEventListener('focusin', sync);
    document.addEventListener('focusout', sync);
    document.addEventListener('keyup', sync);
    return () => {
      document.removeEventListener('focusin', sync);
      document.removeEventListener('focusout', sync);
      document.removeEventListener('keyup', sync);
    };
  }, []);

  return (
    <div className="w-full max-w-sm">
      <div className="rounded-lg border border-border bg-card p-3">
        <label className="mb-1 block text-xs text-muted-foreground" htmlFor="city-good">
          City
        </label>
        <input
          id="city-good"
          role="combobox"
          aria-expanded="true"
          aria-controls="city-good-list"
          // Focus stays here. This attribute POINTS at the active option instead
          // of moving to it — assistive tech announces the option, the caret,
          // the IME and the software keyboard all stay put.
          aria-activedescendant={
            matches.length > 0 ? `city-good-opt-${activeIndex}` : undefined
          }
          value={query}
          onChange={(e) => {
            setQuery(e.target.value);
            setActive(0);
          }}
          onKeyDown={(e) => {
            if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
            e.preventDefault();
            e.stopPropagation();
            const next = activeIndex + (e.key === 'ArrowDown' ? 1 : -1);
            setActive(Math.min(Math.max(next, 0), matches.length - 1));
          }}
          className="w-full rounded border border-input bg-background px-2 py-1 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        />

        <div
          id="city-good-list"
          role="listbox"
          aria-label="Cities"
          className="mt-2 space-y-0.5"
        >
          {matches.map((city, index) => (
            <div
              key={city}
              id={`city-good-opt-${index}`}
              role="option"
              aria-selected={index === activeIndex}
              onClick={() => {
                setQuery(city);
                setActive(0);
              }}
              className={`cursor-pointer rounded px-2 py-1 text-sm ${
                index === activeIndex
                  ? 'bg-primary text-primary-foreground'
                  : 'text-foreground'
              }`}
            >
              {city}
            </div>
          ))}
          {matches.length === 0 && (
            <p className="px-2 py-1 text-sm text-muted-foreground">No matches</p>
          )}
        </div>
      </div>

      <p className="mt-3 rounded border border-border bg-muted px-3 py-2 text-xs text-foreground">
        <span className="text-muted-foreground">document.activeElement:</span>{' '}
        <span className="font-medium text-success">{activeElement}</span>
        <br />
        <span className="text-muted-foreground">Input value:</span>{' '}
        <span className="font-medium">{query || '(empty)'}</span>
      </p>

      <p className="mt-3 text-xs text-muted-foreground">
        Click the field and press ArrowDown a few times — the highlight moves, but the
        readout never stops saying <code>&lt;input&gt;</code>. Keep typing and the list
        keeps filtering. Note the trade: <code>aria-activedescendant</code> owes you the
        scrolling that roving tabindex got free from the browser, so a long list must be
        scrolled into view by hand.
      </p>

      <p className="mt-2 text-xs text-success">
        DOM focus stays in the input; only the pointer to the active option moves
      </p>
    </div>
  );
}
```

## References

- [APG: Developing a Keyboard Interface — Managing Focus Within Components](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)
- [APG: Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/)
