The HTML aria-activedescendant attribute
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
<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-activedescendantwhen 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.























