References

Beginner-friendly references for web development, with live, editable examples.

The HTML aria-activedescendant attribute

ARIA Accessibility
In short

The aria-activedescendant attribute names the active item in a widget by id while DOM focus stays on the widget. A combobox input keeps the caret, and each arrow key makes your script point the attribute at another option. Browsers don't update the value or draw the highlight for you.

Overview

The aria-activedescendant attribute points to the active element while a combobox, listbox or similar widget keeps DOM focus. Its value is a single id, and the browser reports the element with that id to assistive technology as the focused one.

The attribute depends on that DOM focus. It is defined for an element holding DOM focus, and the target is marked as focused only while that element has it. An input can take focus already, while a listbox built on a <ul> needs tabindex="0" to join the Tab order.

Styling and key handling stay with you. The browser doesn't style the active descendant, and :focus can't match it, because real focus is on the container. Keyboard events fire at the focused element, so your handler there reads the arrow keys and rewrites the value.

The target has to meet one of two conditions. The value can name an element the focused element owns, meaning a descendant or a child added with aria-owns. A combobox, textbox or searchbox can instead use aria-controls to reach a popup and name an element the popup owns.

A <select>, or an input linked to a <datalist>, leaves the popup and its keys to the browser, so build the custom pattern when those controls can't do the job.

Syntax

<ul role="listbox" aria-label="Cities" tabindex="0"
  aria-activedescendant="city-2">
  <li id="city-1" role="option" aria-selected="false">Lisbon</li>
  <li id="city-2" role="option" aria-selected="true">Madrid</li>
</ul>

Values

Value
A single element id.

Example

Live example
<p style="margin:0 0 8px">Focus the field, then press Down, Up, Enter or Escape, or type to filter.</p>
<label for="city">Ship to</label>
<input id="city" role="combobox" aria-expanded="false" aria-controls="cities" aria-autocomplete="list" autocomplete="off">
<p id="state" role="status" style="margin:8px 0">aria-activedescendant: not set.</p>
<ul id="cities" role="listbox" aria-label="Cities" hidden style="margin:0;padding:0;list-style:none;max-width:14em;border:1px solid #767676">
  <li id="city-1" role="option" aria-selected="false">Lisbon</li>
  <li id="city-2" role="option" aria-selected="false">Madrid</li>
  <li id="city-3" role="option" aria-selected="false">Oslo</li>
  <li id="city-4" role="option" aria-selected="false">Paris</li>
</ul>
<script>
const input = document.getElementById('city');
const list = document.getElementById('cities');
const state = document.getElementById('state');
const options = [...list.children];
let shown = options;
let active = -1;

function report() {
  const id = input.getAttribute('aria-activedescendant');
  const target = id ? id + ' (' + document.getElementById(id).textContent + ')' : 'not set';
  state.textContent = 'DOM focus: ' + (document.activeElement.id || 'page') + '. aria-activedescendant: ' + target + '.';
}
function setActive(i) {
  active = i;
  options.forEach(o => {
    const on = o === shown[i];
    o.setAttribute('aria-selected', on);
    o.style.background = on ? '#1e72e0' : '';
    o.style.color = on ? '#fff' : '';
  });
  if (i < 0) {
    input.removeAttribute('aria-activedescendant');
  } else {
    input.setAttribute('aria-activedescendant', shown[i].id);
    shown[i].scrollIntoView({ block: 'nearest' });
  }
  report();
}
function setOpen(open) {
  list.hidden = !open;
  input.setAttribute('aria-expanded', open);
  if (!open) setActive(-1);
}
function pick(option) {
  input.value = option.textContent;
  setOpen(false);
}
input.addEventListener('input', () => {
  const query = input.value.trim().toLowerCase();
  shown = options.filter(o => {
    o.hidden = !o.textContent.toLowerCase().includes(query);
    return !o.hidden;
  });
  setOpen(shown.length > 0);
  setActive(-1);
});
input.addEventListener('keydown', e => {
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
    e.preventDefault();
    if (!shown.length) return;
    setOpen(true);
    const last = shown.length - 1;
    if (e.key === 'ArrowDown') setActive(active < last ? active + 1 : 0);
    else setActive(active > 0 ? active - 1 : last);
  } else if (e.key === 'Enter' && active >= 0) {
    e.preventDefault();
    pick(shown[active]);
  } else if (e.key === 'Escape') {
    setOpen(false);
  }
});
input.addEventListener('blur', () => setOpen(false));
list.addEventListener('mousedown', e => e.preventDefault());
list.addEventListener('click', e => {
  if (e.target !== list) pick(e.target);
});
</script>

Best practices

  • Remove aria-activedescendant when the popup closes. The active descendant should be visible, and a hidden option gets no link in Chrome's accessibility tree.
  • Scroll the active option into view each time it changes, with scrollIntoView({ block: 'nearest' }) for example. Moving the value doesn't scroll anything by itself.
  • Set an id on every option when you render it, since the attribute has nothing else to point at.
  • Keep mouse clicks from taking focus away. Calling preventDefault() on the popup's mousedown event leaves DOM focus in the input, and your click handler still runs.

Frequently asked questions

How does aria-activedescendant work?
The aria-activedescendant attribute leaves DOM focus on one element and tells assistive technology that another element is active. Your script changes the id as the user presses arrow keys.
Which roles support aria-activedescendant?
The roles that support aria-activedescendant are application, combobox, group, textbox and the abstract composite role. Through inheritance it also reaches grid, listbox, menu, menubar, radiogroup, row, searchbox, spinbutton, tablist, toolbar, tree and treegrid.
Is aria-activedescendant valid on a dialog or a button?
WAI-ARIA 1.2 doesn't list aria-activedescendant for the dialog or button roles. Chrome's accessibility tree still shows the relation on both, so the browser won't flag that mistake for you.
How many ids can aria-activedescendant hold?
The aria-activedescendant attribute holds one id, because its value type is a single ID reference. A value that doesn't exactly match an existing id, such as two ids, is an author error that matches no element.
Should you use aria-activedescendant or a roving tabindex?
Use aria-activedescendant when DOM focus has to stay put, such as in a combobox input. A roving tabindex moves real focus between items, and the browser then scrolls each item into view.
Does aria-activedescendant need aria-controls?
A combobox, textbox or searchbox needs aria-controls when aria-activedescendant names an option in a separate popup. Without that link, the option is only a valid target if the focused element owns it.