Ever been stuck debugging a custom dropdown or a combo box that ignores keyboard focus in weird ways? Maybe you made the list items tabindex="-1" so they’re not in the tab order, and relied on aria-activedescendant to point to the selected item. But then, pressing arrow keys doesn’t update the screen reader’s focus the way you expect. Or worse, the browser’s native focus ring seems to vanish.
I hit exactly this while building a complex widget with a roving tabindex pattern. It looked perfect visually, but keyboard users and screen reader users got lost. Turns out, understanding how browsers handle tabindex="-1" and aria-activedescendant isn’t just about reading specs , it’s about grasping what the browser really does with keyboard focus under the hood.
Let me share what I learned about these two key pieces, how browsers manage focus, and how you can spot and fix common pitfalls.
The curious case of tabindex="-1"
You probably know: tabindex="0" makes an element keyboard focusable in the natural tab order. tabindex="-1" makes an element focusable programmatically, but skips it in the tab sequence.
That sounds straightforward. But here’s the catch:
- You can call
.focus() on a tabindex="-1" element, and it accepts keyboard focus.
- But pressing Tab will never land on it.
- Some browsers show the focus ring only for keyboard-initiated focus (Tab key), so focusing programmatically might hide the ring.
Why use tabindex="-1" at all? Because sometimes you want to move focus behind the scenes , like when a custom widget manages selection and keyboard navigation internally.
Example: A combo box where the input is focused, but the active item in the dropdown is not focused in the DOM, only referenced via aria-activedescendant. The list items have tabindex="-1" so they’re not tabbable, but can be focused by scripts if needed.
What about aria-activedescendant?
aria-activedescendant is a little-known but powerful ARIA attribute. It tells assistive technologies: "Hey, the element with this id inside me is the active item." The focus stays on the container, but the screen reader virtually moves focus to the referenced element.
This is crucial for widgets like listboxes or comboboxes where focus should remain on the input or container, but the active selection changes.
However, aria-activedescendant only works if the container element itself is keyboard focusable (usually tabindex="0" or naturally focusable). If the container isn’t focused, the screen reader has no anchor to follow.
How browsers actually manage keyboard focus here
Here’s the key: browsers track a single focused element in the DOM , the one that receives keyboard events, shows the native focus ring, and is the anchor for assistive tech.
When you set aria-activedescendant on that focused element, screen readers use it to redirect their virtual focus indication to the referenced item.
But the referenced item itself is not focused. It’s just pointed to.
If you try to focus an item with tabindex="-1" programmatically (like calling .focus()), that item becomes the browser focus target. But if you want to keep the input focused and just update the virtual active descendant, don’t call .focus() on the list item.
This distinction matters because:
- The native focus ring appears on the focused element (the container or input), not the active descendant.
- Keyboard events go to the focused element.
- Screen readers announce the active descendant as the "current item" inside the container.
Common traps and debugging tips
Trap 1: Forgetting to focus the container
If your container element (the one with aria-activedescendant) isn’t focused, screen readers won’t follow the active descendant. Your list items might update visually, but the a11y focus won’t move.
Fix: Make sure the container is focusable (tabindex="0" or native) and programmatically focus it on widget open.
Trap 2: Setting tabindex="-1" on active items and calling .focus()
If you call .focus() on a tabindex="-1" list item, you move actual DOM focus there, which breaks keyboard input on the container.
Fix: Instead of moving DOM focus, update the container’s aria-activedescendant to point to the active item. Keep focus on the container.
Trap 3: Expecting native focus ring on active descendant
Browsers don’t show the native focus ring on the active descendant because it’s not actually focused.
Fix: Use CSS styles on the active item to highlight it visually. Use :focus-visible on the container for keyboard focus ring.
Trap 4: Keyboard event handling confusion
Since keyboard events go to the focused container, your widget’s keyboard logic must listen on the container, not list items.
Fix: Attach keyboard event handlers to the container and update aria-activedescendant accordingly.
Putting it all together: a concrete example
Imagine a custom listbox implemented like this:
- A
<div role="listbox" tabindex="0" aria-activedescendant="item-3"> is the keyboard focus target.
- Inside are
<div role="option" id="item-1" tabindex="-1"> elements.
- User presses ArrowDown.
- Widget code updates
aria-activedescendant to "item-4", but does NOT call .focus() on that option.
- The container remains focused, keyboard events continue to work, and screen readers announce the newly active item.
This pattern makes keyboard navigation smooth and accessible.
Why browsers chose this design
Managing a single focused element reduces complexity for assistive tech and keeps keyboard event routing predictable.
If every interactive item could be focused in the DOM, keyboard navigation would become chaotic in complex widgets.
aria-activedescendant lets you keep one focus anchor but still indicate selection changes inside a composite widget.
Wrapping up
If you’re building custom widgets, understanding the interplay of tabindex="-1" and aria-activedescendant is a game changer.
Make sure:
- The container is focusable and focused.
- Keyboard events are handled on the container.
- You update
aria-activedescendant to reflect the active item.
- You never move DOM focus to the active item itself.
- Visual highlight styles compensate for missing native focus ring on active descendants.
With this clarity, you’ll avoid subtle bugs, make your widgets friendlier to keyboard and screen reader users, and make debugging focus issues way less painful.