The accessibility tree and the keyboard path

A screen reader and the Tab key see a different page from the one you are looking at.

The idea

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.

A settings form with its computed tab order, its heading outline, and the accessible-name chain for the focused element

How it 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

Cost

AuditTimeWhat it needs
Heading outlineO(h)The levels in reading order, nothing else
Tab orderThe sort over positive tabindex values
Accessible nameO(1) per elementAn id lookup for the labelledby
Alt-text sweepO(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.

Watch out for

Worked example

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.

Check yourself

A page has one h1, then an h4, then an h2. Which of these is the outline problem?