How to check visible focus indicator

Reach each control with the Tab key, then read the computed outline-style and box-shadow of document.activeElement. A control with outline-style: none and no shadow has no indicator. Reading outline-width instead is the trap: it comes back as 3px on a control whose ring was removed.

Why check this

Run this whenever a design system ships new styles, and on any screen where a stylesheet reset has touched outline. The failure it prevents is quiet: a keyboard user tabs into the payment form, the ring is gone, and the page gives no sign of where the next Enter will land. Nothing throws, nothing looks broken on a mouse pass, and the control still works, which is why the defect survives release after release.

The check reads what Chrome computed for the focused element, so it answers reachability and styling in one pass. It does not measure the ring against the background. That comparison needs the colour of the ring and the colour of the pixels underneath it, which is what the contrast checker above takes.

Prerequisites

// focus-demo.mjs  -  the two buttons this check is about
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.

    Tab through the page and print the three properties that decide whether an indicator exists.

    // focus-style.mjs  -  node focus-style.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.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, c = getComputedStyle(e);
        return `${e.tagName.toLowerCase()}#${e.id || '-'} focus-visible=${e.matches(':focus-visible')} `
          + `outline-style=${c.outlineStyle} outline-width=${c.outlineWidth} box-shadow=${c.boxShadow}`;
      }));
    }
    await browser.close();
    
    node focus-style.mjs http://localhost:8794/ 9
    
    1. input#news focus-visible=true outline-style=auto outline-width=1px box-shadow=none
    …
    6. button#cancel focus-visible=true outline-style=auto outline-width=1px box-shadow=none
    7. button#save focus-visible=true outline-style=none outline-width=3px box-shadow=none
    8. button#fancy focus-visible=true outline-style=none outline-width=3px box-shadow=rgb(15, 118, 110) 0px 0px 0px 3px
    9. input#card focus-visible=true outline-style=auto outline-width=1px box-shadow=none

    Stops 7 and 8 both report outline-width: 3px with no outline drawn. outline: none sets the style to none and leaves the width at its initial medium, which computes to 3px.

  3. Step 3.

    Focus one healthy button twice, once with the mouse and once with Tab, and compare.

    // mouse-vs-tab.mjs  -  node mouse-vs-tab.mjs <url> <selector> <presses-to-reach-it>
    import { launch } from 'puppeteer-core';
    const [url, sel, n = '4'] = process.argv.slice(2);
    const browser = await launch({ channel: 'chrome', headless: true });
    const page = await browser.newPage();
    const read = () => page.evaluate(() => {
      const e = document.activeElement, c = getComputedStyle(e);
      return `focus=${e.id} :focus-visible=${e.matches(':focus-visible')} outline-style=${c.outlineStyle} outline-width=${c.outlineWidth} outline-color=${c.outlineColor}`;
    });
    await page.goto(url, { waitUntil: 'networkidle2' });
    await page.click(sel);
    console.log('mouse click : ' + await read());
    await page.reload({ waitUntil: 'networkidle2' });
    for (let i = 0; i < Number(n); i++) await page.keyboard.press('Tab');
    console.log('Tab key     : ' + await read());
    await browser.close();
    
    node mouse-vs-tab.mjs http://localhost:8794/ "#back" 4
    
    mouse click : focus=back :focus-visible=false outline-style=none outline-width=3px outline-color=rgb(0, 0, 0)
    Tab key     : focus=back :focus-visible=true outline-style=auto outline-width=1px outline-color=rgb(16, 16, 16)

    One button, two readings. The mouse line carries the same three outline values as the broken #save line in step 2.

  4. Step 4.

    Name the rule that removed the ring, so the ticket points at a line instead of a symptom.

    // outline-rules.mjs  -  node outline-rules.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(() => {
      let read = 0, blocked = 0;
      const rules = [...document.styleSheets].flatMap((s) => {
        try { const r = [...s.cssRules]; read++; return r; } catch { blocked++; return []; }
      });
      const hits = rules.filter((r) => /outline\s*:\s*(none|0)\b/.test(r.cssText)).map((r) => '  ' + r.cssText);
      return `${read} stylesheet(s) read, ${blocked} blocked by CORS, ${rules.length} rules\n`
        + (hits.join('\n') || '  (no rule removes the outline)');
    }));
    await browser.close();
    
    node outline-rules.mjs http://localhost:8794/
    
    1 stylesheet(s) read, 0 blocked by CORS, 8 rules
    #save:focus { outline: none; }
    #fancy:focus { outline: none; box-shadow: rgb(15, 118, 110) 0px 0px 0px 3px; }
  5. Step 5.

    Point the same script at a site that keeps its rings, and read the sheet counters.

    node outline-rules.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP
    
    17 stylesheet(s) read, 0 blocked by CORS, 381 rules
    (no rule removes the outline)

    The counters are the point. A cross-origin stylesheet raises on cssRules and would be counted as blocked, so a clean result with blocked above zero means the audit read less than the page uses.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | outline-style: auto | The browser default ring is drawn | Nothing. Check its contrast if the background is dark | | outline-style: none, box-shadow: none | No indicator at all | Fix the rule step 4 named. This fails WCAG 2.4.7 | | outline-style: none with a box-shadow | A custom ring replaces the default | Measure it. A 3px shadow clears the 2 CSS px rule | | :focus-visible=false | Chrome does not treat this focus as keyboard focus | Reach the control with Tab and read it again | | outline-width: 3px on its own | Nothing. The width is the initial value | Read outline-style before believing any width |

Common mistakes

Sign: An audit reads outline-width, finds 3px, and passes a control that shows no ring.Cause: outline: none sets outline-style to none and leaves outline-width at its initial value, medium, which Chrome computes as 3px. Both broken buttons in step 2 report 3px. The style is the property that decides, and the width is meaningless without it.
Sign: A control with a working ring is reported as having no indicator.Cause: The control was focused with a mouse click. Chrome matches :focus-visible on keyboard focus, so a clicked button computes outline-style: none and outline-width: 3px, the same three outline values a genuinely broken button reports under Tab. Clicking an element to select it in DevTools produces the same false failure.
Sign: A design system passes the outline check and the ring is still invisible on a dark card.Cause: Both criteria are about being seen, not about a property existing. outline-color came back as rgb(16, 16, 16) on this capture, which is near black. Against a dark surface that is an indicator only in the stylesheet.

Thresholds

At least as large as a 2 CSS pixel thick perimeter of the control, with a contrast ratio of at least 3:1 between the focused and unfocused states Source: WCAG 2.2 Success Criterion 2.4.13 Focus Appearance, Level AAA (w3.org/WAI/WCAG22/Understanding/focus-appearance.html)

The #fancy button draws 0 0 0 3px, a 3px ring on all four sides, which clears the size half of that number. Level AA, criterion 2.4.7, sets no number at all and asks only that a visible indicator exists.

What to check next

FAQ

What is a focus indicator?

The mark a browser draws around the control that will receive the next keystroke. In Chrome it is an outline with outline-style: auto, drawn by the user agent stylesheet on :focus-visible. Authors replace it with an outline of their own or with a box-shadow ring.

What is a visible focus indicator?

The term from WCAG 2.4.7, Level AA: any keyboard operable interface has a mode of operation where the keyboard focus indicator is visible. The criterion names no size and no colour. The numbers arrive at Level AAA with criterion 2.4.13.

Why does the ring appear when I press Tab but not when I click?

Chrome draws its default ring on :focus-visible, which matches keyboard focus and not a mouse click on a button. Step 3 shows both readings for the same control. Test with the Tab key, or the result describes the mouse rather than the page.

Is replacing the outline with a box-shadow a failure?

Not by itself. A shadow ring is visible and can be thicker than the default. It sits outside the element and can be clipped by an ancestor with overflow: hidden, so confirm the ring is drawn on the control in place, not only in the computed style.

Can I check this without a browser?

No. The rule is in the stylesheet, the ring is drawn by the browser, and :focus-visible depends on how focus arrived. A stylesheet search finds outline: none, which step 4 does more precisely, but it cannot tell you whether anything was put back.

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