Skip to content

Build1 publisher2 min readPublished

Calling .focus() on a tabindex="-1" option takes keyboard input away from the listbox

Browsers keep exactly one focused element in the DOM. A dev.to walkthrough of custom dropdown bugs shows what happens to keyboard and screen reader users when a widget moves real focus into the list and sets aria-activedescendant as well.

The Engineer · Build desk

Illustration accompanying Calling .focus() on a tabindex="-1" option takes keyboard input away from the listbox

What happened

  • A dev.to walkthrough takes apart custom dropdowns and combo boxes where list items carry tabindex="-1" and the active option is identified only by aria-activedescendant on the container.
  • tabindex="-1" keeps an element out of the tab sequence while leaving it focusable by script, so .focus() moves keyboard focus to it even though Tab never will.
  • Browsers track a single focused element in the DOM, and it is the element that receives keyboard events, shows the native focus ring and anchors assistive technology.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • decision Where the keydown handler lives is decided by the focus model, so a widget that moves DOM focus into the list and keeps its handlers on the container has to give up one of the two.
  • constraint The highlight on the active option is CSS the team writes and maintains, because the browser will not draw its ring on an element that is only referenced.
  • exposure A visual check misses the people who hit this defect. That is how a widget that looks correct ships with keyboard and screen reader navigation broken.

A widget gets one focus model, and the rest of the code follows from which one it gets. Keep DOM focus on the container and the keydown handler belongs on the container, because keyboard events go to whichever element the browser considers focused [7][13]. Once `.focus()` moves focus onto an option, that option is the browser's focus target, and the post says keyboard input on the container breaks [9]. The rule it gives is blunt: attach handlers to the container and update `aria-activedescendant` instead of moving focus [11][13].

The container has to be focusable and actually focused before any of the ARIA work counts for anything. `aria-activedescendant` only functions when the container is keyboard focusable, usually `tabindex="0"` or natively focusable, and the fix in the post is to focus it programmatically when the widget opens [6][10]. A widget that skips that step still updates the list items on screen while accessibility focus stays where it was [6].

Then there is the ring. Browsers draw the native focus ring on the focused element, and the active descendant is only pointed to, never focused [7][8]. Visible state therefore needs two rules: CSS on the active option, plus `:focus-visible` on the container for the keyboard ring [12]. There is a second trap in the same area, because some browsers show the ring only for focus initiated by the Tab key, so a programmatic `.focus()` can produce no ring at all [4]. The post attributes that to "some browsers" without naming one, so a team using a visible ring as an acceptance criterion is testing a claim about an unspecified engine [14].

This is one developer's debugging narrative. "It looked perfect visually, but keyboard users and screen reader users got lost," the author wrote of a complex widget built with a roving tabindex pattern [15][16]. For that account to predict your widget's behaviour, the cause has to be the focus model itself, whatever engine and screen reader happen to be in play.

What carries over regardless is the browser behaviour the post states plainly: one focused element in the DOM at a time, taking keyboard events and anchoring assistive technology [7]. A widget that calls `.focus()` on options and also sets `aria-activedescendant` keeps two records of which item is current, and only one of them is the one the browser acts on. The worked example in the post opens with a div carrying `role="listbox"` and `tabindex="0"` [17].

What to watch

  • The listbox example in the post breaks off inside the opening div tag; the complete snippet would show whether the keydown handler is bound to the container.
  • Naming the browsers that suppress the ring on programmatic focus would turn the ring advice from a precaution into a testable condition.
  • A second account from a widget using roving tabindex alone, with no aria-activedescendant, would separate the two patterns' failure modes.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories