DEV Community

Cover image for Element.focus vs aria-activedescendant
Zell Liew 🤗
Zell Liew 🤗

Posted on • Originally published at zellwk.com

Element.focus vs aria-activedescendant

When you build JavaScript components, you need to manage focus for both keyboard users and screen readers. The WAI-ARIA specs say there are two ways to manage focus:

  1. Using element.focus and tabindex
  2. Using aria-activedescendant

Which should you use and why?

I did in-depth research on these two methods and I'd like to share my findings in this article. Take a seat and grab some popcorn because it's going to be a long article.

First, let's take a look at aria-activedescendant since it's foreign to most developers (other than accessibility people).

aria-activedescendant

aria-activedescendant is commonly placed on a container element. It lets screen readers identify (and hence say) the element that's supposed to be active.

You need to do four things to make aria-activedescendant work.

  1. Add the aria-activedescendant to an ancestor element. This ancestor element can be a composite widget. If the element is not a composite widget, it must have a textbox, group, or application role.
  2. Make this ancestor element focusable
  3. Set aria-activedescendant to the id of the active item.
  4. Style the active item so users can see a difference visually

:::note
There are 9 composite widgets according to the spec: combobox, grid, listbox, menu, menubar, radiogroup, tablist, tree, and treegrid
:::

Let's put aria-activedescendant into context by building something together. We'll let a user choose a character from a list of characters.

The correct role for this list is a listbox. Items in a listbox are selectable while items in a list aren't. Children of listboxes should have the option role.

Here's the HTML.

<ul role="listbox" tabindex="0">
  <li role="option" id="mickey">Mickey</li>
  <li role="option" id="minnie">Minnie</li>
  <li role="option" id="donald">Donald</li>
  <li role="option" id="daisy">Daisy</li>
  <li role="option" id="goofy">Goofy</li>
</ul>
Enter fullscreen mode Exit fullscreen mode

When a user selects a character, we need to set aria-activedescendant on listbox to the id of the selected character.

For example, let's say the user selects Minnie. The correct HTML would be:

<ul role="listbox" tabindex="0" aria-activedescendant="minnie">
  <li role="option" id="mickey">Mickey</li>
  <li role="option" id="minnie">Minnie</li>
  <li role="option" id="donald">Donald</li>
  <li role="option" id="daisy">Daisy</li>
  <li role="option" id="goofy">Goofy</li>
</ul>
Enter fullscreen mode Exit fullscreen mode

We also need to change the CSS so users know (visually) that Minnie got selected. We can only do this reliably through a class.

<ul role="listbox" tabindex="0" aria-activedescendant="minnie">
  <li role="option" id="mickey">Mickey</li>
  <li role="option" id="minnie" class="is-selected">Minnie</li>
  <li role="option" id="donald">Donald</li>
  <li role="option" id="daisy">Daisy</li>
  <li role="option" id="goofy">Goofy</li>
</ul>
Enter fullscreen mode Exit fullscreen mode

For now, let's allow users to select characters by clicking on them. The JavaScript for this widget can be:

const listbox = document.querySelector('[role="listbox"]');
const characters = [...listbox.children];

listbox.addEventListener("click", event => {
  const option = event.target.closest("li");
  if (!option) return;

  // Sets aria-activedescendant value
  listbox.setAttribute("aria-activedescendant", option.id);

  // Change visual appearance
  characters.forEach(element => element.classList.remove("is-selected"));
  option.classList.add("is-selected");
});
Enter fullscreen mode Exit fullscreen mode

We need to test the widget with screen readers. In this case, both Voiceover and NVDA were able to say the active item.


Voiceover says the clicked item


NVDA says the clicked item

:::note
There are tiny differences between what each screen reader says. It's not important to normalise what they say. What's important is ensuring all screen readers say the active item.
:::

This is only level 1. Blind users won't be able to click on elements. We need to let them select options with Up and Down arrow keys.

Onward to level 2.

Selecting options with arrow keys

Let's make things easier by setting the first element as the active descendant.

<ul role="listbox" tabindex="0" aria-activedescendant="mickey">
  <li role="option" id="mickey" class="is-selected">Mickey</li>
  <li role="option" id="minnie">Minnie</li>
  <li role="option" id="donald">Donald</li>
  <li role="option" id="daisy">Daisy</li>
  <li role="option" id="goofy">Goofy</li>
</ul>
Enter fullscreen mode Exit fullscreen mode

If the user presses Down, we want to set Minnie as the active descendant. To do this, we listen to a keydown event.

listbox.addEventListener("keydown", event => {
  const { key } = event;
  if (key !== "ArrowDown") return;
  // ...
});
Enter fullscreen mode Exit fullscreen mode

We check for the currently active descendant element. This should be Mickey.

listbox.addEventListener("keydown", event => {
  // ...
  const activeElementID = listbox.getAttribute("aria-activedescendant");
  const activeElement = listbox.querySelector("#" + activeElementID);
});
Enter fullscreen mode Exit fullscreen mode

Then, we find the next element.

listbox.addEventListener("keydown", event => {
  // ...
  const selectedOption = activeElement.nextElementSibling;
});
Enter fullscreen mode Exit fullscreen mode

Then, we set the active descendant to this new element.

listbox.addEventListener("keydown", event => {
  // ...
  const nextElement = activeElement.nextElementSibling;
  if (nextElement) {
    // Sets aria-activedescendant value
    listbox.setAttribute("aria-activedescendant", selectedOption.id);

    // Change visual appearance
    characters.forEach(element => element.classList.remove("is-selected"));
    selectedOption.classList.add("is-selected");
  }
});
Enter fullscreen mode Exit fullscreen mode

We do the same thing if the user presses the Up arrow key. Here's the complete code.

listbox.addEventListener("keydown", event => {
  const { key } = event;
  if (key !== "ArrowDown" && key !== "ArrowUp") return;

  const activeElementID = listbox.getAttribute("aria-activedescendant");
  const activeElement = listbox.querySelector("#" + activeElementID);

  let selectedOption;
  if (key === "ArrowDown") selectedOption = activeElement.nextElementSibling;
  if (key === "ArrowUp") selectedOption = activeElement.previousElementSibling;

  if (selectedOption) {
    // Sets aria-activedescendant value
    listbox.setAttribute("aria-activedescendant", selectedOption.id);

    // Change visual appearance
    characters.forEach(element => element.classList.remove("is-selected"));
    selectedOption.classList.add("is-selected");
  }
});
Enter fullscreen mode Exit fullscreen mode

Again, both Voiceover and NVDA were able to say the active item.


Voiceover says the active item


NVDA says the active item

Element.focus + tabindex

Let's build the same thing above. This time, we'll use element.focus to move DOM focus instead of relying on aria-activedescendant.

First, we want to create the HTML. For this HTML, we don't need to give each option an id since we won't be using the id.

<ul role="listbox">
  <li role="option">Mickey</li>
  <li role="option">Minnie</li>
  <li role="option">Donald</li>
  <li role="option">Daisy</li>
  <li role="option">Goofy</li>
</ul>
Enter fullscreen mode Exit fullscreen mode

When a user clicks on an option, we want to move DOM focus to that option. To move DOM focus, we need to make sure each option is focusable. The easiest way to do this is to add tabindex to each option.

We'll set tabindex to -1.

<ul role="listbox">
  <li role="option" tabindex="-1">Mickey</li>
  <li role="option" tabindex="-1">Minnie</li>
  <li role="option" tabindex="-1">Donald</li>
  <li role="option" tabindex="-1">Daisy</li>
  <li role="option" tabindex="-1">Goofy</li>
</ul>
Enter fullscreen mode Exit fullscreen mode

We can use the focus method to select the option. Here's the JavaScript:

const listbox = document.querySelector('[role="listbox"]');

listbox.addEventListener("click", event => {
  const option = event.target.closest("li");
  if (!option) return;

  option.focus();
});
Enter fullscreen mode Exit fullscreen mode

We also need to change the visual style of the selected item. We can use the :focus pseudo-selector to help us do this.

li:focus {
  background: aquamarine;
}
Enter fullscreen mode Exit fullscreen mode

Both Voiceover and NVA were able to say the active item.


Voiceover says the active item


NVDA says the active item

Let's move on to Level 2.

Selecting options with arrow keys

As before, let's make things easier by selecting the first element. In this case, we can "select" an element by setting tabindex to 0.

By setting a tabindex to 0, we allow users to Tab to the element as we enter the listbox. We can also use the tabindex="0" to style the CSS.

<ul role="listbox">
  <li role="option" tabindex="0">Mickey</li>
  <li role="option" tabindex="-1">Minnie</li>
  <li role="option" tabindex="-1">Donald</li>
  <li role="option" tabindex="-1">Daisy</li>
  <li role="option" tabindex="-1">Goofy</li>
</ul>
Enter fullscreen mode Exit fullscreen mode
/* Styles the selected option */
li[tabindex="0"] {
  background: aquamarine;
}
Enter fullscreen mode Exit fullscreen mode

If the user presses Down, we want to select Minnie. To do this, we need to listen to a keyboard event.

listbox.addEventListener("keydown", event => {
  const { key } = event;
  if (key !== "ArrowDown") return;
  // ...
});
Enter fullscreen mode Exit fullscreen mode

We can find Minnie immediately with nextElementSibling.

listbox.addEventListener("keydown", event => {
  // ...
  const option = event.target; // This is Mickey
  const selectedOption = option.nextElementSibling; // This is Minnie
});
Enter fullscreen mode Exit fullscreen mode

Then we change the tabindex values to select Minnie.

listbox.addEventListener("keydown", event => {
  // ...
  if (selectedOption) {
    // Focus on next element
    selectedOption.focus();

    // Roving Tabindex
    characters.forEach(element => {
      element.setAttribute("tabindex", -1);
    });
    selectedOption.setAttribute("tabindex", 0);
  }
});
Enter fullscreen mode Exit fullscreen mode

I found it useful to prevent the default behavior of arrow keys. This prevents Voiceover from activating "Next Item" when we press the Down arrow key.

listbox.addEventListener("keydown", event => {
  // ...
  if (key !== "ArrowDown") return;
  event.preventDefault();
  // ...
});
Enter fullscreen mode Exit fullscreen mode

We'll do the same steps if the user presses the Up arrow key. Here's the completed code (with some cleanup):

listbox.addEventListener("keydown", event => {
  const { key } = event;
  if (key !== "ArrowDown" && key !== "ArrowUp") return;
  event.preventDefault();
  const option = event.target;

  let selectedOption;
  if (key === "ArrowDown") selectedOption = option.nextElementSibling;
  if (key === "ArrowUp") selectedOption = option.previousElementSibling;

  if (selectedOption) {
    selectedOption.focus();
    characters.forEach(element => {
      element.setAttribute("tabindex", -1);
    });
    selectedOption.setAttribute("tabindex", 0);
  }
});
Enter fullscreen mode Exit fullscreen mode

Again, both Voiceover and NVDA were able to say the selected item.


Voiceover says the selected item


NVDA says the selected item

Comparing code between the two options

The spec says aria-activedescendant is an alternative method to manage focus without moving DOM focus among descendant elements. This hints that aria-activedescendant can be easier to use compared to the element.focus + tabindex combination.

However, this doesn't seem to be the case in practice. I found the aria-activedescendant version longer and more complicated.


Comparing code between the focus method (left) and aria-activedescendant method (right)

¯\_(ツ)_/¯

Problems with aria-activedescendant and Voiceover

On further testing, I realised that Voiceover doesn't say the active element when used on combobox and grid roles. Let's talk about my findings on comboboxes first.

Combobox

A combobox is an element that contains two things:

  1. A single-line textbox
  2. A popup box that helps a user set the value of the textbox. This popup box can be listbox, grid, tree, or dialog.

A Typeahead (often called Autocomplete) is an example of a combobox.

Google's autocomplete is an example of a combobox.

I tried setting aria-activedescendant on the Combobox element. When I did this, Voiceover refuses to say elements that are selected with aria-activedescendant.

Voiceover doesn't say the active element

But it works on NVDA.

NVDA says the active element

Combobox Take 2

There's a second way to use aria-activedescendant with Comboboxes. We can set aria-activedescendant on the textbox. When we do this, we must also set aria-owns to indicate that the textbox owns the listbox.

<div role="combobox">
  <input
    type="text"
    id="text"
    aria-owns="listbox"
    aria-activedescendant="mickey"
  />
  <ul role="listbox" id="listbox">
    <li role="option" id="mickey" class="is-selected">Mickey</li>
    <li role="option" id="minnie">Minnie</li>
    <li role="option" id="donald">Donald</li>
    <li role="option" id="daisy">Daisy</li>
    <li role="option" id="goofy">Goofy</li>
  </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

Both Voiceover and NVDA were able to say the active descendant when the input is empty.


Voiceover says the active item


NVDA says the active item

However, Voiceover does not say the active descendant if the input is filled.


Voiceover doesn't say active descendant

It works on NVDA though.


NVDA says the active descendant

¯\_(ツ)_/¯

Grid

For grids, we can let users select one of these:

  1. Select the entire row
  2. Select a single cell

When I allowed users to select the entire row, Voiceover says "2 rows selected" regardless of the content.


Voiceover says 2 rows selected

NVDA announces the selected row.


NVDA announces the selected row

When I allowed users to select one cell, Voiceover says nothing.


Voiceover doesn't say active cell

NVDA says the cell content, row, column header, and column.


NVDA announces the cell content, row, column, and column header

Comboboxes and grids with element.focus + tabindex

Voiceover doesn't say the active descendant when we used aria-activedescendant on comboboxes and rows. This is a big problem since Voiceover has a large share of the market.

How does the element.focus + roving tabindex method fare? Let's find out.

Combobox

Both Voiceover and NVDA says the item with DOM focus when the input is empty.


Voiceover says the active item


NVDA says the active item

There were also able to say the active item when the input is filled.


Voiceover says the active item


NVDA says the active item

Grid

When I allowed users to select the entire row, Voiceover announces the first cell in the row.


Voiceover says the selected row

NVDA announces the selected row.


NVDA says the selected row

When I allowed users to select one cell, Voiceover says the cell contents.


Voiceover says the cell

NVDA says the cell contents, the row, the column header, and the column number.


NVDA says the cell, row, and column

Codepen links

I created a collection of pens (one for each test) if you'd like to run the experiments yourself. Here's the link to the collection.

Conclusion

There is only one reliable method for managing focus: element.focus + roving tabindex.

Don't use aria-activedescendant. It doesn't work on grid and combobox with Voiceover. I don't know how aria-activedescendant works with other composite widgets since I didn't test them. If you decide to use aria-activedescendant, make sure you test your code with screen readers before putting it into production.

I don't see the benefits of using aria-activedescendant since the amount of code required for element.focus + roving tabindex is similar to the amount of code required for aria-activedescendant.

After my experiments, I can't help but think aria-activedescendant is poop (just like how Heydon Pickering considers aria-controls poop). Of course, if I'm wrong about my conclusions, please reach out and educate me! Thanks!


Thanks for reading. This article was originally posted on my blog. Sign up for my newsletter if you want more articles to help you become a better frontend developer.

Top comments (0)