Skip to content

About

Measure a keyboard focus indicator in pixels and check it against WCAG SC 1.4.11 — plus an audit of 15 classless CSS frameworks

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

4 Commits

Folders and files

Repository files navigation

focus-ring-audit

Tab through your own site. Can you see where you are?

If you can't, neither can anyone who navigates by keyboard — and there is a good chance you never wrote a line of CSS to cause it. Some stylesheets replace the focus ring the browser already drew correctly with one that composites away to nothing.

This repo is two things:

  1. A measuring tool. Point it at your page and a selector. It renders twice — unfocused and focused — subtracts the screenshots, and reports the WCAG contrast of every pixel that changed. Whatever changed is the focus indicator, so it does not care whether the indicator came from outline, box-shadow, a border, a background swap or a pseudo-element, and it sees the alpha compositing that a CSS reader cannot.
  2. An audit of 15 classless CSS frameworks, run with that tool, with the exact line of CSS responsible for each failure and a fix that was measured rather than guessed.

The result: 4 of the 15 classless frameworks leave at least one of a link, a text input and a button with a focus indicator below 3:1 — Pico CSS (on buttons), water.css, holiday.css, and Bamboo, which fails on all three. The other 11 pass, most of them for the same reason: they leave the ring the browser already drew alone. Two class-based frameworks were measured as reference points, and both fail: Bootstrap 5 and Milligram. That headline is the light theme; Bamboo and holiday.css ship a dark block and both pass in it, which is measured below and is part of the diagnosis, not a footnote.

If you use one of the four, the fix is at the bottom and it was measured, not guessed.

Why a screenshot and not a CSS parser

Automated accessibility scanners do not check this. axe-core, Lighthouse and friends cannot see :focus styles at all — the state only exists during interaction, and the rules they run look at the resting DOM. That gap is the whole reason this is worth measuring.

Reading the CSS by hand does not work either, and the audit below is the proof. Every framework that fails here declares a focus ring — a real one, two or three pixels thick, in a sensible colour. Not one of them writes outline: none and stops. They fail in three different ways, and all three are invisible to anyone grepping for outline:

what the CSS says what the browser draws
an alpha channel on the ring colour box-shadow: 0 0 0 2px rgba(2,154,232,.5) composited over the page: rgb(128,204,244) — 1.77:1
a pale opaque colour box-shadow: 0 0 0 2px #88c0d0 exactly what it says — 2.00:1
a pale colour and a blur radius box-shadow: 0 0 .2rem .01rem <colour> a glow whose peak stops short of a colour that was already too pale — 1.58:1

Nothing in any of those lines says "invisible". The pixels do.

Check your own site

Needs chromium (or chrome) on PATH and Pillow. No npm, no headless framework, no network service.

pip install pillow
python3 measure.py --url https://example.com --selector "button.cta"

It prints the changed-pixel count, the best contrast any of them reaches, and a verdict against SC 1.4.11. To check a stylesheet instead of a page, on a built-in demo page with a link, a text input and a button:

python3 measure.py --css https://cdn.jsdelivr.net/npm/water.css@2/out/light.css

And to test a fix before you ship it — same harness, your override layered on the real stylesheet, so the "after" number is comparable to the "before" one:

python3 try_fix.py --css <your stylesheet> --selector "#btn" \
    --fix ':focus-visible{outline:3px solid #005fcc;outline-offset:2px}'

Is the sensor real?

python3 controls.py runs four stylesheets whose answer is known before the run, and it is the first thing to run if you doubt a number here:

control stylesheet expected measured
C0 none — the browser's own ring visible, high contrast 16.55–19.03:1
C1 :focus{outline:6px solid red} visible, ~4:1 on white 4.00:1, 1320–2716 px
C2 :focus-visible{…}, same ring identical to C1 identical to C1
C3 outline:0, borders removed nothing changes at all 0 changed pixels

C2 is the one everything else rests on. The harness focuses elements with a programmatic .focus(), and most modern frameworks style :focus-visible, not :focus. If programmatic focus did not match :focus-visible in this browser, every one of them would come out as "no visible change" and the table below would be a fabrication of the harness. C1 and C2 come out identical, so it does. C3 is the floor: a stylesheet that truly draws nothing must read as zero.

The audit

Measured 2026-09-14, Chromium headless, 900×700, default light colour scheme. Each cell is the best contrast any changed pixel reaches when that element takes focus; bold is below 3:1. Stars fetched the same day, for ordering only.

Classless frameworks

framework ★ link text input button verdict (SC 1.4.11 AA)
Pico CSS 16858 3.55:1 5.23:1 1.77:1 fails on button
water.css 8647 19.03:1 2.27:1 2.27:1 fails on text input, button
MVP.css 5132 19.03:1 19.03:1 5.39:1 passes on all three
Simple.css 5011 19.03:1 19.03:1 8.63:1 passes on all three
Sakura 4388 18.07:1 4.79:1 18.07:1 passes on all three
new.css 4047 19.03:1 17.87:1 19.03:1 passes on all three
LaTeX.css 3479 5.02:1 19.03:1 16.55:1 passes on all three
matcha.css 1936 19.03:1 19.03:1 19.03:1 passes on all three
Tacit 1883 19.03:1 19.03:1 19.03:1 passes on all three
Marx 1709 5.24:1 5.24:1 5.24:1 passes on all three
concrete.css 634 19.03:1 18.88:1 ⚠ 18.88:1 ⚠ passes on all three
Bahunya 312 18.92:1 4.55:1 3.97:1 passes on all three
Bamboo 280 2.00:1 2.00:1 2.00:1 fails on link, text input, button
holiday.css 159 19.03:1 1.58:1 1.54:1 fails on text input, button
awsm.css — 19.03:1 9.04:1 9.04:1 passes on all three

Reference points (class-based, not part of the claim)

framework ★ link text input button verdict (SC 1.4.11 AA)
Bootstrap 5 174797 19.03:1 1.58:1 1.84:1 fails on text input, button
Milligram 10216 19.03:1 3.19:1 1.10:1 fails on button

⚠ concrete.css clears the bar on 16 pixels. That is a pass, because SC 1.4.11 sets a contrast threshold and no minimum area — but 16 pixels is not a focus indicator anyone can find, and it is exactly what the area leg of SC 2.4.13 exists to catch. results.json carries the changed_px and area_ratio for every row; the table cannot show you that in one number, so read the JSON before quoting a pass as an endorsement.

The table above is the light theme. Two of the four pass in the dark one

Bamboo and holiday.css both ship a prefers-color-scheme: dark block, so the verdict can depend on which theme the visitor gets. Measured with --blink-settings=preferredColorScheme=0, with a sheet that has no dark block at all (Tacit) as the paired control — it returns the same numbers with the flag and without it, which is what makes the dark column publishable:

link text input button
Bamboo, light 2.00:1 2.00:1 2.00:1
Bamboo, dark 6.24:1 6.24:1 6.24:1
holiday.css, light 19.03:1 1.58:1 1.54:1
holiday.css, dark 13.88:1 4.61:1 4.28:1

Same stylesheet, same ring, opposite verdict. In Bamboo the cause is visible in the source: the dark block re-declares --b-txt, --b-bg-1, --b-bg-2 and --b-line, and --b-focus is not among them, so a light Nord blue meant for a dark page is also what you get on a white one.

What happened upstream (checked 2026-09-29)

Each failure was reported to its project, with the numbers and a measured fix. The table above is still what you get from the published packages today; this is where each one stands.

framework report state
holiday.css #347 → #348 fixed in main (merged 2026-09-28, 1051805c), not yet released: npm is still at 0.11.6. The ring gets its own variable (#767676 light, #bcbebd dark) and a solid outline instead of the blur. Measured on the merge commit built with the project's own npm run build: 4.54:1 on input and button in light (was 1.58 / 1.54), 8.62:1 in dark (was 4.61 / 4.28). The parent commit, measured in the same batch, reproduced the old numbers to the hundredth
Pico CSS #751 open. Its maintainer-successor says Pico has gone unmaintained for over a year; the fork that is maintained, Blades CSS, took the report over as anyblades/blades#167 (open)
water.css #394 open, no reply; the repo had no push between Feb 2024 and mid-Sep 2026
Bamboo #7 open, no reply
Milligram (reference) #449 open

One thing the Blades report made clear: focus colours built as a translucent tint of the primary have a hard ceiling. At 25% alpha no base colour can pass on white. Pure black at .25 composites to rgb(191,191,191), which is 1.84:1, so that ring cannot be fixed by picking a better colour; the alpha has to go (python3 alpha_check.py "rgba(0,0,0,0.25)" "#ffffff").

The where-it-comes-from, for the four that fail:

framework the line why it fails
Pico CSS --pico-primary-focus: rgba(2, 154, 232, .5) on button:focus's box-shadow 50% alpha; composites to rgb(128, 204, 244) on white
water.css button:focus{box-shadow: 0 0 0 2px #0096bfab} ab is 67% alpha
Bamboo :focus{outline:none; box-shadow: 0 0 0 2px var(--b-focus)}, --b-focus: #88c0d0 no alpha at all — the colour is simply too pale
holiday.css button:focus{outline:0; box-shadow: 0 0 .2rem .01rem var(--border-hover-color)} the declared colour is #b5b5b5, which is 2.05:1 on white even as a hard ring; the .2rem of blur takes the peak down to 1.54

python3 focus_rules.py <stylesheet-url> prints that for any sheet, and python3 alpha_check.py "rgba(2,154,232,0.5)" "#ffffff" does the compositing arithmetic on its own, as a second opinion independent of the screenshots. On every row above the two agree to the hundredth: 1.77, 2.00, 2.27.

One warning about focus_rules.py: it finds rules whose selector contains focus. A stylesheet that kills the native ring with a plain button{outline:none} is invisible to it, and to every other CSS reader. That is the case for the screenshot method.

The fix

If you own the site: one rule — and it needs !important

This is the part I got wrong first, so it leads. The advice everybody gives is "just write your own focus ring". Measured against the eleven failing (framework, element) pairs above, layered on the real stylesheet exactly as a site owner would layer it:

a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible,
textarea:focus-visible, summary:focus-visible, [tabindex]:focus-visible,
[role=button]:focus-visible {
  outline: 3px solid #005fcc !important;
  outline-offset: 2px !important;
}
override pairs it fixes
the rule above, without !important 6 / 11
a two-tone ring (outline:2px solid #000 + box-shadow:0 0 0 6px #fff,0 0 0 8px #000), without !important 7 / 11
the rule above, with !important 11 / 11

The last row is six measurements plus five: the six that already pass without !important are not re-measured with it, and the five it skips were, one by one — holiday.css input 5.98:1 over 4137 px, holiday.css button 5.98:1 over 795, Milligram button 5.98:1 over 1017, Bootstrap 5 input 5.98:1 over 4933, Bootstrap 5 button 5.98:1 over 850. Each of those runs re-measured the "before" in the same command, and all five landed on the shipped number to the hundredth (1.58, 1.54, 1.10, 1.58, 1.84), which is the reproducibility check.

Without the !important it does not degrade — it does nothing, silently, and your page measures exactly as it did before you wrote it: holiday.css stayed at 1.54:1 on the button to the hundredth, Milligram at 1.10:1, Bootstrap 5 at 1.84:1. The cause is specificity, and holiday.css shows it in one line:

button:focus, button:not([type]):focus, ... { outline: 0 }

button:not([type]):focus is (0,2,1). A plain button:focus-visible is (0,1,1), so it loses no matter where you put it in the file, and no matter how late your stylesheet loads. Same rule with !important: 5.98:1 over 795 px, a pass. (For Milligram and Bootstrap 5 I did not chase the exact selector that outranks it. The measured fact is the same flip.)

The colour is not sacred — #005fcc is a dark blue that clears 3:1 on white. On a dark page you want the opposite, and the two-tone ring is the one that survives both without you knowing the background.

If you maintain one of the four

The failure is one declaration in each, and the smallest honest fix is to stop compositing the ring against the page:

framework today measured fix
Pico CSS --pico-primary-focus: rgba(2, 154, 232, .5) dropping the alpha (#029ae8) gets 1.77 → 3.09:1, which clears the bar by 0.09. #0172ad — the opaque colour Pico's own text input ring already uses — gets 5.23:1
water.css --focus: #0096bfab #0096bf — the same colour, without the ab
Bamboo --b-focus: #88c0d0 #5e81ac (Nord 10) in the light theme, keeping #88c0d0 in the dark block: 4.03:1 / 6.24:1. The same colour in both themes also passes, but only 3.10:1 in dark
holiday.css box-shadow: 0 0 .2rem .01rem <colour> not a hard ring on its own: same colour without blur is still 2.05:1. --light-border-hover-color: #767676 plus a hard ring gets 4.54:1 on input and button. #595959 alone, blur intact, gets 5.06 / 3.23

Every one of those is a variable, which is the good news: you can test the change without touching a selector, and try_fix.py measures it before you ship it.

One trap if you are overriding Pico from your own stylesheet rather than patching it: :root{--pico-primary-focus:#0172ad} changes nothing, measured — Pico declares that variable inside a block with a higher specificity than :root, so a plain :root override loses in silence, exactly like the missing !important above. Both numbers in the row come from an override that carries !important.

Pico CSS in its dark theme

The table is light-only. Pico ships a dark theme, and it is measured separately in pico_dark.json (_pico_dark.py is the same harness with data-theme="dark" on the <html>): the link reaches 3.50:1, the text input 3.43:1 — both pass — and the button reaches 2.01:1, a fail. So Pico's button fails in both themes, for the same reason: --pico-primary-focus is rgba(1, 170, 255, .375) there, and 37.5% alpha over Pico's rgb(19, 22.5, 30.5) page composites to rgb(12, 78, 115) — 2.01:1, which is the number alpha_check.py predicts to the hundredth before any screenshot is taken.

I expected the dark text input to fail too, because in dark Pico's --pico-form-element-focus-color stops being an opaque border colour and becomes that same translucent variable. It passes: on focus the input's border changes to an opaque #01aaff as well, and those pixels carry the indicator. That is the whole argument for measuring pixels instead of reading CSS, and it cuts against me as easily as for me.

What this does not tell you

Read METHOD.md before quoting a row. The short version:

  • A passing row is not an accessible framework. It says one thing: the keyboard focus indicator on that element reaches 3:1 against what it replaced. Nothing about text contrast, semantics, ARIA, target size, or anything else.
  • A failing row is a failure of the default, not a verdict on a site. Any of them is four lines of your own CSS away from fixed, and this README says which four.
  • Three elements, one browser, one viewport, one theme. A link, a text input and a <button>, in Chromium headless at 900×700 with the default light colour scheme. A framework can pass here and fail on its own dark theme (water.css does exactly that, and both were measured), or on checkboxes, selects and custom widgets, which were not.
  • A demo page, not a real site. SC 1.4.11 is about adjacent colours. Put a control on a coloured card and a ring that passes here can fail there.
  • Only the AA number (aa_1411) is exact. The area leg of SC 2.4.13 is approximated, and the JSON reports it as a ratio, never as a verdict.

Prior art

The closest thing I know of is Darek Kay's The state of accessible web UI frameworks, which rates focus rings across around thirty popular component kits on a 0–4 scale. If you are choosing between the big component libraries, start there. This is a different question — classless frameworks, judged by a measured ratio per element instead of a rating, with the harness attached so you can rerun it on your own stylesheet or your own page. (That page answers 403 to this host, so I make no claim about what its table contains today.)

For the criterion itself: Understanding SC 1.4.11 Non-text Contrast is explicit that state information — "whether a component is selected or focused" — needs 3:1. It is the AA bar, and it is the one that EN 301 549, Section 508 and Spain's RD 1112/2018 point at.

A note on the number, because I got it wrong myself and published it wrong before fixing it: in old working drafts, Focus Appearance was numbered 2.4.11 at AA. In the WCAG 2.2 Recommendation it is 2.4.13 at AAA, and 2.4.11 is Focus Not Obscured. Citing the draft number turns an AA failure into something a maintainer can reasonably read as optional.

Reproducing the table

python3 controls.py                  # the sensor check above -> controls.json
python3 run_corpus.py --tries 2      # the corpus            -> results.json
python3 make_table.py results.json   # the markdown above
python3 fix_candidates.py --tries 2  # the fix table         -> fixes.json
python3 _pico_dark.py                # Pico's dark theme     -> pico_dark.json
python3 dark_theme.py --tries 2      # both themes + control -> dark_theme.json
python3 maintainer_fixes.py          # Bamboo/holiday fixes  -> maintainer_fixes.json
python3 colour_pairs.py              # which two colours     -> colour_pairs.json

colour_pairs.py is the one to reach for if you don't believe a number: it prints the exact #rrggbb → #rrggbb transition behind it and how many pixels take it, so the WCAG arithmetic can be redone by hand without running anything else.

Every measurement is repeated and any row whose two runs disagree is reported as UNSTABLE rather than averaged. results.json records the sha256 of every stylesheet as fetched, so a row can be pinned even after the CDN moves under it.


MIT. Built and measured by Midas. Corrections welcome — especially a row you can't reproduce.

About

Measure a keyboard focus indicator in pixels and check it against WCAG SC 1.4.11 — plus an audit of 15 classless CSS frameworks

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages