aria-activedescendant
Identifies the currently active element when focus is on a composite widget, combobox, textbox, group, or application. Used for managing focus in complex widgets where DOM focus remains on the container.
Overview
The aria-activedescendant attribute is used to manage focus in composite widgets where the container element maintains DOM focus while a descendant element is visually indicated as active or selected.
This is particularly useful for widgets like listboxes, comboboxes, trees, and grids where keyboard navigation moves selection through child elements without actually moving DOM focus. The value must be the ID of a descendant element that is currently active.
Why Use aria-activedescendant?
Moving DOM focus to each option in a listbox would cause screen readers to exit “browse mode” and read the entire option content. Using aria-activedescendant keeps focus on the container while announcing only the active option, providing a better user experience.
Live Demo: Combobox with aria-activedescendant
Instructions:
- Type to filter the list
- Use ↑ ↓ to navigate
- Press Enter to select
- Press Esc to clear
Note: The input maintains focus while aria-activedescendant indicates the active option
When to Use aria-activedescendant
Good Use Cases
- ✓Listbox: Focus on container, highlight moves through options
- ✓Combobox: Focus on input, highlight in dropdown list
- ✓Tree: Focus on tree container, navigate through nodes
- ✓Grid: Focus on grid, move through cells
- ✓Toolbar: Focus on toolbar, highlight active tool
Don't Use For
- ✗Radio groups: Use native focus management instead
- ✗Tab panels: Tabs should receive actual focus
- ✗Single elements: No need if only one focusable child
- ✗Static content: Not for non-interactive elements
- ✗Native widgets: Use browser defaults when possible
Requirements
1Container Must Be Focusable
The element with aria-activedescendant must be focusable (have tabindex) or be a focusable element like <input>.
2Value Must Reference Valid ID
The value must be the ID of a DOM element that is a descendant of the element with aria-activedescendant or is controlled by it via aria-controls.
3Referenced Element Must Be Visible
The referenced element must be visible in the DOM (not display:none or aria-hidden="true") for screen readers to announce it.
4Update on Keyboard Navigation
The attribute value must be updated programmatically as the user navigates with keyboard (arrow keys, etc.). Screen readers will announce the newly active element.
Code Examples
Basic Listbox
<!-- Basic Listbox with aria-activedescendant -->
<div id="fruit-listbox"
role="listbox"
tabindex="0"
aria-activedescendant="option-1">
<div id="option-0" role="option">Apple</div>
<div id="option-1" role="option" aria-selected="true">Banana</div>
<div id="option-2" role="option">Cherry</div>
</div>
<script>
const listbox = document.getElementById('fruit-listbox');
let selectedIndex = 1;
const options = listbox.querySelectorAll('[role="option"]');
listbox.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % options.length;
updateActiveDescendant();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = (selectedIndex - 1 + options.length) % options.length;
updateActiveDescendant();
}
});
function updateActiveDescendant() {
// Update aria-activedescendant
listbox.setAttribute('aria-activedescendant', options[selectedIndex].id);
// Update aria-selected
options.forEach((opt, idx) => {
opt.setAttribute('aria-selected', idx === selectedIndex);
});
// Scroll into view if needed
options[selectedIndex].scrollIntoView({ block: 'nearest' });
}
</script>Combobox with Autocomplete
<!-- Combobox with aria-activedescendant -->
<label for="fruit-combo">Choose a fruit:</label>
<input
type="text"
id="fruit-combo"
role="combobox"
aria-expanded="true"
aria-autocomplete="list"
aria-controls="fruit-list"
aria-activedescendant="fruit-option-0"
/>
<ul id="fruit-list" role="listbox">
<li id="fruit-option-0" role="option" aria-selected="true">Apple</li>
<li id="fruit-option-1" role="option">Apricot</li>
<li id="fruit-option-2" role="option">Avocado</li>
</ul>
<script>
const combobox = document.getElementById('fruit-combo');
const listbox = document.getElementById('fruit-list');
let activeIndex = 0;
combobox.addEventListener('keydown', (e) => {
const options = listbox.querySelectorAll('[role="option"]');
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = Math.min(activeIndex + 1, options.length - 1);
updateActive();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, 0);
updateActive();
} else if (e.key === 'Enter') {
e.preventDefault();
combobox.value = options[activeIndex].textContent;
combobox.setAttribute('aria-expanded', 'false');
}
});
function updateActive() {
const options = listbox.querySelectorAll('[role="option"]');
// Update aria-activedescendant on combobox
combobox.setAttribute('aria-activedescendant', options[activeIndex].id);
// Update visual styling
options.forEach((opt, idx) => {
opt.classList.toggle('active', idx === activeIndex);
});
// Ensure visible
options[activeIndex].scrollIntoView({ block: 'nearest' });
}
</script>Tree Widget
<!-- Tree widget with aria-activedescendant -->
<div id="file-tree"
role="tree"
tabindex="0"
aria-activedescendant="node-1"
aria-label="File Browser">
<div id="node-1" role="treeitem" aria-expanded="true" aria-level="1">
Documents
<div role="group">
<div id="node-1-1" role="treeitem" aria-level="2">
Resume.pdf
</div>
<div id="node-1-2" role="treeitem" aria-level="2">
Cover Letter.docx
</div>
</div>
</div>
<div id="node-2" role="treeitem" aria-expanded="false" aria-level="1">
Photos
</div>
</div>
<script>
const tree = document.getElementById('file-tree');
const treeItems = Array.from(tree.querySelectorAll('[role="treeitem"]'));
let activeIndex = 0;
tree.addEventListener('keydown', (e) => {
const activeItem = treeItems[activeIndex];
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
activeIndex = Math.min(activeIndex + 1, treeItems.length - 1);
updateActiveDescendant();
break;
case 'ArrowUp':
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, 0);
updateActiveDescendant();
break;
case 'ArrowRight':
e.preventDefault();
if (activeItem.getAttribute('aria-expanded') === 'false') {
activeItem.setAttribute('aria-expanded', 'true');
} else {
// Move to first child if expanded
const nextItem = treeItems[activeIndex + 1];
if (nextItem && parseInt(nextItem.getAttribute('aria-level')) >
parseInt(activeItem.getAttribute('aria-level'))) {
activeIndex++;
updateActiveDescendant();
}
}
break;
case 'ArrowLeft':
e.preventDefault();
if (activeItem.getAttribute('aria-expanded') === 'true') {
activeItem.setAttribute('aria-expanded', 'false');
} else {
// Move to parent
const currentLevel = parseInt(activeItem.getAttribute('aria-level'));
for (let i = activeIndex - 1; i >= 0; i--) {
if (parseInt(treeItems[i].getAttribute('aria-level')) < currentLevel) {
activeIndex = i;
updateActiveDescendant();
break;
}
}
}
break;
}
});
function updateActiveDescendant() {
tree.setAttribute('aria-activedescendant', treeItems[activeIndex].id);
// Update visual styling
treeItems.forEach((item, idx) => {
item.classList.toggle('active', idx === activeIndex);
});
treeItems[activeIndex].scrollIntoView({ block: 'nearest' });
}
</script>React Component
// React Combobox with aria-activedescendant
import { useState, useRef, useEffect } from 'react';
function Combobox({ options, label }) {
const [value, setValue] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef(null);
const listboxRef = useRef(null);
const filteredOptions = options.filter(opt =>
opt.toLowerCase().includes(value.toLowerCase())
);
useEffect(() => {
setActiveIndex(0);
}, [value]);
const handleKeyDown = (e) => {
if (filteredOptions.length === 0) return;
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
setIsOpen(true);
setActiveIndex((prev) =>
Math.min(prev + 1, filteredOptions.length - 1)
);
break;
case 'ArrowUp':
e.preventDefault();
setIsOpen(true);
setActiveIndex((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (isOpen && filteredOptions[activeIndex]) {
setValue(filteredOptions[activeIndex]);
setIsOpen(false);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
break;
}
};
const activeDescendantId = isOpen && filteredOptions.length > 0
? `option-${activeIndex}`
: undefined;
return (
<div className="combobox-wrapper">
<label htmlFor="combobox-input">{label}</label>
<input
ref={inputRef}
id="combobox-input"
type="text"
role="combobox"
aria-expanded={isOpen}
aria-autocomplete="list"
aria-controls="combobox-listbox"
aria-activedescendant={activeDescendantId}
value={value}
onChange={(e) => {
setValue(e.target.value);
setIsOpen(true);
}}
onKeyDown={handleKeyDown}
onFocus={() => setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 200)}
/>
{isOpen && filteredOptions.length > 0 && (
<ul
ref={listboxRef}
id="combobox-listbox"
role="listbox"
aria-label={label}
>
{filteredOptions.map((option, index) => (
<li
key={option}
id={`option-${index}`}
role="option"
aria-selected={index === activeIndex}
className={index === activeIndex ? 'active' : ''}
onClick={() => {
setValue(option);
setIsOpen(false);
inputRef.current?.focus();
}}
>
{option}
</li>
))}
</ul>
)}
</div>
);
}
// Usage
function App() {
const fruits = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'];
return (
<Combobox
options={fruits}
label="Choose a fruit"
/>
);
}Best Practices
Update aria-activedescendant whenever keyboard navigation changes the active element
Ensure the referenced element has a unique ID attribute
Keep the active element visible by scrolling it into view when needed
Use with composite widgets (listbox, tree, grid) not simple controls
Pair with aria-selected or other state attributes on child elements
Make sure the container element is focusable (has tabindex)
Test with multiple screen readers to ensure proper announcements
Don't use if you can move actual DOM focus instead
Don't reference elements that are hidden or aria-hidden="true"
Don't forget to update the value - stale references confuse users
Don't use on elements that aren't focusable
Don't reference elements outside the controlled container
Accessibility Notes
Screen Reader Behavior
When aria-activedescendant changes, screen readers will announce the newly active element's content. This is more efficient than moving actual focus, especially in large lists. The screen reader stays in browse/reading mode, allowing users to hear just the option text without additional context.
Browser Support
aria-activedescendant is well-supported in modern browsers and screen readers. However, behavior can vary slightly between screen reader/browser combinations. Always test with NVDA, JAWS, and VoiceOver to ensure consistent experience.
Performance Consideration
Using aria-activedescendant is more performant than repeatedly moving DOM focus, especially in large lists or trees. Moving focus triggers style recalculations, reflows, and scroll adjustments. With aria-activedescendant, only visual styling needs to update.
Visual Indicators
Always provide clear visual styling for the active element. Since DOM focus remains on the container, sighted keyboard users rely entirely on visual cues (background color, border, icon) to understand which element is active. Use high-contrast colors and clear indicators.
Compatible Roles
comboboxDropdown with autocomplete
textboxEditable text field
groupGroup of UI elements
applicationApplication region
listboxList of selectable options
treeHierarchical list
gridData grid/table
compositeComposite widgets