A screen reader and the Tab key see a different page from the one you are looking at.
The browser builds a second structure alongside the page you see. It has a name for every control, a role that says what kind of thing it is, and an order that the Tab key walks. That structure is what assistive technology reads, and it is derived from your markup by rules that are precise, small, and easy to get wrong by accident.
Three of those rules cover most of what an audit finds. Headings form an outline, and skipping a level breaks the document into a shape nobody can navigate. Tab order is not visual order: a positive tabindex jumps the whole queue. And an element's accessible name comes from a precedence chain, where the first source that produces text wins and the rest are ignored, even if they are the ones you actually wrote.
Auto play is off because your system asks for reduced motion. Press Tab still works.
Both computations are short, and writing them out is the fastest way to stop guessing at them.
def tab_order(elements):
"""Positive tabindex first, ascending, then everything at 0 in document
order. Negative is focusable by script but never by Tab."""
positive = [e for e in elements if e.tabindex > 0]
natural = [e for e in elements if e.tabindex == 0]
positive.sort(key=lambda e: (e.tabindex, e.doc_index)) # ties keep doc order
return positive + natural
def accessible_name(el, by_id):
"""First source that yields text wins. Everything after it is ignored."""
if el.labelledby and by_id.get(el.labelledby):
return by_id[el.labelledby].text # aria-labelledby
if el.label:
return el.label # aria-label
if el.own_text:
return el.own_text # the element's own content
return None # unnamed, and it will be read as its role alone
def outline_problems(headings):
"""A level may go down by any amount, but only up by one."""
out, prev = [], 0
for h in headings:
if prev and h.level > prev + 1:
out.append((h, prev))
prev = h.level
return out
| Audit | Time | What it needs |
|---|---|---|
| Heading outline | O(h) | The levels in reading order, nothing else |
| Tab order | The sort over positive tabindex values | |
| Accessible name | O(1) per element | An id lookup for the labelledby |
| Alt-text sweep | O(images) | Only the distinction between absent and empty |
All of it is cheap to compute, which is why these checks belong in CI rather than in a quarterly review. What is expensive is finding out after launch that the order somebody tabs through your checkout is not the order they read it in.
tabindex="3" does not move to third place, it moves ahead of every element that has 0, which is all of them. It also puts you in the business of renumbering the whole page every time you add a field. Use 0 and fix the source order instead.alt="" says this image is decorative, skip it. No alt at all says nothing, and the screen reader falls back to reading the file name. One of those is a decision and the other is an omission, and an audit that treats them alike will report the wrong problems.aria-label="Submit form" is announced as "Submit form". Someone using voice control now says a word that is nowhere on screen and nothing happens.Take the checkbox in the form above. Its markup carries all three of aria-labelledby="lbl-notify", an aria-label, and the visible text of its own label element.
The chain resolves in order. aria-labelledby points at an element that exists and has text, so that text is the name and the other two sources are never consulted. Change the id it points at to something that is not on the page and the chain falls through to aria-label. Someone reading the markup sees three labels and assumes the most specific one wins. What actually wins is the first one in the chain.
Now Tab through it. The Save button carries tabindex="3", so keyboard focus lands there first, before the checkbox above it. Nothing about the page looks wrong. The order is simply not the order anyone reading it would expect.
A page has one h1, then an h4, then an h2. Which of these is the outline problem?