How to check focus order

Record the element and its screen position at every Tab press, then sort the same list by top and left and compare the two orders. Where they disagree, the eye and the Tab key go different ways. The script below prints both sequences and the first stop where they part.

Why check this

Run this after any layout change that moves controls without moving the markup, and on every form and dialog before release. The failure is specific. In the capture below the button row reads Back, Cancel, Continue from left to right, while Tab visits Back, Continue, Cancel. A keyboard user who tabs once past Back and presses Enter activates Continue and submits the order, having aimed at Cancel.

The check compares the tab sequence with the painted layout. It says nothing about whether each stop is reachable, which is the keyboard navigation pass, and nothing about whether the ring is visible once focus arrives. It also cannot decide for you whether an order that differs from the layout is wrong, because WCAG 2.4.3 asks for meaning and operation to be preserved, not for the sequence to track the pixels.

Prerequisites

// focus-demo.mjs  -  the two order defects are in the .row rule and on #news
import { createServer } from 'node:http';
const page = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Focus demo</title><style>
 body{font:16px system-ui;margin:2rem;max-width:34rem}
 .row{display:flex;gap:.5rem}
 #back{order:1} #cancel{order:2} #next{order:3}   /* visual order, not DOM order */
 #save:focus{outline:none}                         /* indicator removed */
 #fancy:focus{outline:none;box-shadow:0 0 0 3px #0f766e}  /* ring drawn by box-shadow */
 .fakebtn{display:inline-block;background:#eee;border:1px solid #999;padding:.2rem .6rem}
</style></head><body>
<a href="#main">Skip to content</a>
<h1 id="main">Checkout</h1>
<label>Coupon <input id="coupon"></label>
<div class="fakebtn" id="apply" onclick="alert('applied')">Apply</div>
<div class="row"><button id="back">Back</button><button id="next">Continue</button><button id="cancel">Cancel</button></div>
<button id="save">Save card</button>
<button id="fancy">Fancy</button>
<label><input type="checkbox" id="news" tabindex="1"> Email me offers</label>
<label>Card <input id="card"></label>
<script>
 // The card field swallows Tab, the way a hand-rolled autocomplete often does.
 document.getElementById('card').addEventListener('keydown', e => { if (e.key === 'Tab') e.preventDefault(); });
</script></body></html>`;
createServer((_, res) => res.end(page)).listen(8794, () => console.log('http://localhost:8794/'));

Steps

  1. Step 1.

    Start the demo server in its own terminal.

    node focus-demo.mjs
    
    http://localhost:8794/
  2. Step 2.

    Record the tab sequence with the position of each stop on screen.

    // tab-order.mjs  -  node tab-order.mjs <url> <presses>
    import { launch } from 'puppeteer-core';
    const [url, n = '9'] = process.argv.slice(2);
    const browser = await launch({ channel: 'chrome', headless: true });
    const page = await browser.newPage();
    await page.setViewport({ width: 1280, height: 800 });
    await page.goto(url, { waitUntil: 'networkidle2' });
    for (let i = 1; i <= Number(n); i++) {
      await page.keyboard.press('Tab');
      console.log(i + '. ' + await page.evaluate(() => {
        const e = document.activeElement, r = e.getBoundingClientRect();
        const label = (e.id || e.textContent || e.getAttribute('aria-label') || '').trim().slice(0, 16);
        return `${e.tagName.toLowerCase()} "${label}" top=${Math.round(r.top)} left=${Math.round(r.left)} tabindex=${e.tabIndex}`;
      }));
    }
    await browser.close();
    
    node tab-order.mjs http://localhost:8794/ 9
    
    1. input "news" top=193 left=173 tabindex=1
    2. a "Skip to content" top=32 left=32 tabindex=0
    3. input "coupon" top=145 left=93 tabindex=0
    4. button "back" top=168 left=32 tabindex=0
    5. button "next" top=168 left=151 tabindex=0
    6. button "cancel" top=168 left=86 tabindex=0
    7. button "save" top=191 left=32 tabindex=0
    8. button "fancy" top=191 left=112 tabindex=0
    9. input "card" top=191 left=344 tabindex=0

    Two things are already visible. Stop 1 sits at top=193, near the bottom, and stops 5 and 6 run right to left inside one row.

  3. Step 3.

    Sort the same stops into reading order and print the disagreements.

    // reading-order.mjs  -  node reading-order.mjs <url> <presses>
    import { launch } from 'puppeteer-core';
    const [url, n = '9'] = process.argv.slice(2);
    const browser = await launch({ channel: 'chrome', headless: true });
    const page = await browser.newPage();
    await page.setViewport({ width: 1280, height: 800 });
    await page.goto(url, { waitUntil: 'networkidle2' });
    const seq = [];
    for (let i = 0; i < Number(n); i++) {
      await page.keyboard.press('Tab');
      seq.push(await page.evaluate(() => {
        const e = document.activeElement, r = e.getBoundingClientRect();
        return { id: (e.id || e.textContent).trim().slice(0, 12), top: Math.round(r.top), left: Math.round(r.left) };
      }));
    }
    const reading = [...seq].sort((a, b) => a.top - b.top || a.left - b.left);
    console.log('tab order    : ' + seq.map((e) => e.id).join(' -> '));
    console.log('reading order: ' + reading.map((e) => e.id).join(' -> '));
    seq.forEach((e, i) => {
      if (reading[i].id !== e.id) console.log(`  stop ${i + 1}: Tab gives "${e.id}", reading order expects "${reading[i].id}"`);
    });
    await browser.close();
    
    node reading-order.mjs http://localhost:8794/ 9
    
    tab order    : news -> Skip to cont -> coupon -> back -> next -> cancel -> save -> fancy -> card
    reading order: Skip to cont -> coupon -> back -> cancel -> next -> save -> fancy -> card -> news
    stop 1: Tab gives "news", reading order expects "Skip to cont"
    stop 2: Tab gives "Skip to cont", reading order expects "coupon"
    …
    stop 9: Tab gives "card", reading order expects "news"

    Eight mismatch lines for two defects. One displaced element shifts every pair after it, so read the first line and ignore the rest until it is fixed.

  4. Step 4.

    Find what moved. Positive tabindex values and computed order are the two usual causes, and neither is visible in the markup alone.

    // order-causes.mjs  -  node order-causes.mjs <url>
    import { launch } from 'puppeteer-core';
    const browser = await launch({ channel: 'chrome', headless: true });
    const page = await browser.newPage();
    await page.goto(process.argv[2], { waitUntil: 'networkidle2' });
    console.log(await page.evaluate(() => {
      const positive = [...document.querySelectorAll('[tabindex]')].filter((e) => e.tabIndex > 0)
        .map((e) => `  tabindex=${e.tabIndex} on ${e.tagName.toLowerCase()}#${e.id || '-'}`);
      const reordered = [...document.querySelectorAll('*')]
        .filter((e) => { const c = getComputedStyle(e); return c.order !== '0' || /reverse/.test(c.flexDirection); })
        .map((e) => `  ${e.tagName.toLowerCase()}#${e.id || '-'} order=${getComputedStyle(e).order} flex-direction=${getComputedStyle(e).flexDirection}`);
      return `positive tabindex:\n${positive.join('\n') || '  (none)'}\nvisually reordered:\n${reordered.join('\n') || '  (none)'}`;
    }));
    await browser.close();
    
    node order-causes.mjs http://localhost:8794/
    
    positive tabindex:
    tabindex=1 on input#news
    visually reordered:
    button#back order=1 flex-direction=row
    button#next order=3 flex-direction=row
    button#cancel order=2 flex-direction=row

    #next is painted third and sits second in the DOM. The stylesheet moved it, so the markup reads as correct.

  5. Step 5.

    Walk the sequence backwards and confirm it mirrors the forward one.

    // reverse-order.mjs  -  node reverse-order.mjs <url> <presses>
    import { launch } from 'puppeteer-core';
    const [url, n = '8'] = process.argv.slice(2);
    const N = Number(n);
    const browser = await launch({ channel: 'chrome', headless: true });
    const page = await browser.newPage();
    const at = () => page.evaluate(() => (document.activeElement.id || document.activeElement.textContent).trim().slice(0, 12));
    await page.goto(url, { waitUntil: 'networkidle2' });
    const fwd = [];
    for (let i = 0; i < N; i++) { await page.keyboard.press('Tab'); fwd.push(await at()); }
    await page.reload({ waitUntil: 'networkidle2' });
    for (let i = 0; i < N; i++) await page.keyboard.press('Tab');
    const back = [await at()];
    for (let i = 1; i < N; i++) {
      await page.keyboard.down('Shift'); await page.keyboard.press('Tab'); await page.keyboard.up('Shift');
      back.push(await at());
    }
    console.log('Tab       : ' + fwd.join(' -> '));
    console.log('Shift+Tab : ' + back.join(' -> '));
    console.log('mirrored  : ' + (back.join() === [...fwd].reverse().join()));
    await browser.close();
    
    node reverse-order.mjs http://localhost:8794/ 8
    
    Tab       : news -> Skip to cont -> coupon -> back -> next -> cancel -> save -> fancy
    Shift+Tab : fancy -> save -> cancel -> next -> back -> coupon -> Skip to cont -> news
    mirrored  : true

    The press count stops at 8 on purpose. The ninth stop swallows Tab, and a reverse walk that starts inside a trap measures nothing.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | The two orders are the same list | Tab follows the layout | Nothing on this criterion | | The first mismatch names an element with tabindex above 0 | A positive value pulled it in front of the whole document | Set the value to 0 and move the element in the DOM instead | | Two stops in one row run right to left | A stylesheet reordered the row | Reorder the markup and drop the order values | | mirrored: false | Forward and backward walks disagree | Look for a focus handler that moves focus on blur | | mirrored: true with mismatches in step 3 | The order is consistent and still wrong | Consistency is not correctness. Fix the order, then re-run |

Common mistakes

Sign: The reading order diff reports eight failures on a page with two defects.Cause: The comparison is positional. One element out of place shifts every pair after it, so the count inflates. Read only the first mismatch, fix it, and run the script again. A ticket that quotes the eight lines sends someone chasing six stops that are in the right place.
Sign: The Elements panel shows the buttons in the right order, and Tab still visits them in the wrong one.Cause: Tab follows the DOM, and the eye follows the painted layout. Flex order moved Continue to the end of the row without touching the markup, so a source review sees nothing. Step 4 reads the computed order, which is the only place the two disagree.
Sign: The first Tab press lands at the bottom of the page.Cause: An element with a positive tabindex forms its own sequence that runs before every element with tabindex 0, whatever the layout says. In the capture the newsletter checkbox at top=193 comes before the skip link at top=32.

What to check next

FAQ

How to check focus order on a web page?

Press Tab from the address bar and record every stop with its position, as step 2 does. Compare that list against the reading order of the layout. The comparison is the check. A count of stops on its own tells you nothing about order.

How to test focus order and visibility?

They are two passes over the same walk. The script here records position at each stop, the focus indicator check records outline-style and box-shadow at the same stops. Run the order pass first: a stop in the wrong place is worth fixing before its ring is styled.

Does focus order have to match the visual order?

Not exactly. WCAG 2.4.3 asks that focusable components receive focus in an order that preserves meaning and operability. A sequence that differs from the layout can pass when the relationships still hold, so treat a mismatch as a question for the design, not an automatic defect.

What does a positive tabindex do?

It moves the element into a sequence that runs before everything with tabindex="0", in ascending value order, regardless of position in the document. Step 4 finds these. The fix is almost always tabindex="0" plus a move in the markup.

How many Tab presses should I record?

Enough to reach the end of the content you are testing, plus a few more to catch a trap. Record the count with the result, because the number changes whenever a control is added, and a later run that presses fewer times will look like a different page.

Verified

Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate10 minpublished updated Maks Verny