Tab Stop On A Structural Element

ID

html.non_interactive_element_tabindex

Severity

high

Remediation Complexity

trivial

Remediation Risk

low

Remediation Effort

low

Resource

Reliability

Language

Html

Tags

accessibility, reliability

Description

Flags an element put into the tab order although its tag carries a structural role and no interactive role replaces it.

Rationale

A tabindex="0" adds a tab stop, and a tab stop is a promise: the user who lands there expects something to operate. On an element whose role says it is content — a list item, a heading, a paragraph, an image — there is nothing to operate, so every keyboard user pays an extra Tab press for a stop that does nothing, and a screen reader announces content in a position where a control was expected. Long lists make this expensive fast.

<p tabindex="0">Read me</p>                    <!-- FLAW — a tab stop that does nothing -->
<img src="chart.png" alt="Chart" tabindex="0"> <!-- FLAW — same -->
<li tabindex="0">Details</li>                  <!-- FLAW — same, once per list item -->

<table tabindex="0">...</table>                <!-- OK — WCAG 2.1.1 asks for this tab stop -->
<pre tabindex="0">let x = 1;</pre>             <!-- OK — same -->
<li role="option" tabindex="0">One</li>        <!-- OK — the role makes it a control -->
<p tabindex="-1">Read me</p>                   <!-- OK — focusable from script only -->
<div tabindex="0">Custom</div>                 <!-- OK — div has no role to contradict -->
<button tabindex="0">Save</button>             <!-- OK — a control belongs in the tab order -->
<p tabindex="${order}">Read me</p>             <!-- OK — settled at render time -->

Two shapes are commonly meant instead, and neither is reported. tabindex="-1" keeps the element focusable from script — the right tool for moving focus to a region after a change — without adding a tab stop. And an interactive role on the same element (role="option", role="tab") makes the tab stop honest, which is how a composite widget’s items are built.

Only the value 0 is reported, and that is a deliberate narrowing. A positive tabindex is already reported by the rule about the focus order, whose own remediation — bring the value back to 0 or -1 — is the first step here too; reporting both on one attribute would give the author two findings and two different fixes for a single mistake. A negative value is the correct shape and never a finding.

div and span are deliberately not judged: they carry no implicit role for a tab stop to contradict, and a focusable div is the ordinary base for a custom control. The table row and cell tags are not judged either — in a grid they take a tabindex exactly as intended.

table and pre are not judged, for a different reason: their content overflows, and a keyboard user who cannot grab a scrollbar reaches it only by focusing the container and using the arrow keys. WCAG 2.1.1 requires that tab stop and axe-core’s scrollable-region-focusable reports its absence, so telling the author to remove the tabindex would be telling them to undo an accessibility fix.

The judged tabindex has to be plain HTML with a literal value, so th:tabindex and a template expression are left alone. A role the element carries exempts it in either spelling, and so does a role whose value cannot be read here: an unknown role must not be read as the absence of one.

Remediation

Drop the tabindex: content is reached by reading, not by tabbing, and a screen reader already announces it in place. Where the element has to be focusable from script — a region that takes focus after an update — use tabindex="-1". Where it really is a control, give it the interactive role and the key handling that go with the tab stop.

<p>Read me</p>
<div id="results" tabindex="-1">Updated</div>
<li role="option" tabindex="0" onclick="pick()" onkeydown="onKey(event)">One</li>

© 2026 Xygeni. All rights reserved