Loading Developer Playground

Loading ...

Skip to main content
ARIA ATTRIBUTERelationship Attributes

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.

Attribute Type
Relationship
Value Type
ID reference
Used On
Composite widgets

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

combobox

Dropdown with autocomplete

textbox

Editable text field

group

Group of UI elements

application

Application region

listbox

List of selectable options

tree

Hierarchical list

grid

Data grid/table

composite

Composite widgets

Related Attributes

Specifications & Resources