DEV Community

Cover image for Every state of the page passed axe. The page was still broken.
Thomas Sweet
Thomas Sweet

Posted on

Every state of the page passed axe. The page was still broken.

Open the CSS showcase catalog on my site. Filter it to "typography" with the topic chips. Now click a sidebar link that points at a layout showcase.

Nothing happens.

The URL updates. The page does not move. The link is real, its target exists in the document, and it leads nowhere, because the active filter is hiding that target with display: none. For a keyboard user this is a dead end with no feedback of any kind: you activate a link, and the page silently declines.

Here is the part that stuck with me. Axe reports zero violations on that page. Before the click, after the click, filtered, unfiltered. Every individual state is clean, and always was.

Why no scanner can catch this

Automated accessibility tooling evaluates a state. It walks the accessibility tree at a moment in time and checks it against rules: does this control have a name, does this contrast ratio clear the threshold, is this landmark in the right place. Those are excellent questions and axe answers them faster and more consistently than I ever will.

But nothing in this page is malformed at any instant. The filter is a set of radio chips plus :has(), no state management, no JavaScript. The sidebar is anchor links pointing at ids that exist. Both are patterns I would happily recommend. The defect only exists in the composition of the two, and a composition is not a state, so there is no snapshot in which a rule could fire.

This is not a gap in axe's ruleset that a future version will close. It is the shape of the tool.

The fix stayed pure CSS

The project has one hard architectural rule: no unlayered CSS, ever. Everything lives in a cascade layer, which meant the fix was available without touching specificity or reaching for JavaScript.

The filter hides non-matching showcases with display: none from the components layer. A rule in the utilities layer, which sits above it, puts the current :target back — along with the tier group containing it, which the filter may also have collapsed:

@layer utilities {
  .showcase:target,
  .showcase-group:has(.showcase:target) {
    display: grid;
  }
}
Enter fullscreen mode Exit fullscreen mode

Layer order does the whole job. No !important, no specificity arms race, no JavaScript to reset the filter. The linked-to showcase reappears, the filter stays intact for everything else, and neither feature needs to know the other exists.

Two details that matter if you copy this. Keep the selectors scoped to your own classes: a bare :has(:target) matches every ancestor of the target, body and html included. And declare the real value rather than revert-layer, which sounds like the right tool and is not — it rolls back to the value from the layer below, which here is the display: none you are trying to undo.

A keyboard spec now pins the behaviour so it cannot quietly regress: a link to a filtered-out showcase still reveals it. That assertion came from asking what a keyboard user would actually do on this page. It could not have come from a rule.

Which means the steps at the top of this post no longer reproduce. Follow them today and the link lands, because of that rule. I would rather say so than leave the bug up as a museum piece.

What each layer actually caught

The site's last chapter argues that accessibility testing has to be layered. Building the site was the first real test of that argument, so here is the honest inventory, split by when each finding surfaced, because that distinction matters more than a combined list would suggest.

The five testing layers as a row of connected plates, each annotated with what it caught on this site

Caught in CI, before anything shipped.

Stylelint enforces the no-unlayered-CSS rule and mixin ordering; vue-tsc handles types. Vitest guards the theming engine's contrast maths and watches Baseline data for fallback drift. The axe sweep runs every page in Chromium, Firefox and WebKit, and it found two things I would have missed: an icon-only GitHub link with nothing for a screen reader to announce, and a missing color-scheme meta, which let native form controls render light inside a dark theme.

Small, mechanical, entirely real. Running three engines matters more than I expected, too. WebKit and Firefox disagree with Chromium often enough that a single-engine sweep is quietly telling you less than you think.

The Playwright keyboard specs sit here as well, and they pin the things no rule expresses: skip link is the first tab stop, dialog traps focus and closes on Escape, popovers light-dismiss, the theme panel survives a full keyboard round trip, and the filter bug above.

Caught by others, on their phones

A phantom focus ring on iOS: someone opened a dialog demo, closed it, and the content column behind it kept drawing a focus ring. A tabindex="-1" container was receiving programmatic focus. No tool flags a focus ring that should not be there.

Non-colour state cues vanishing in Safari: the theming system renders them through style queries, and WebKit does not apply pseudo-element rules inside a style query. Someone browsing on an iPhone mentioned, almost in passing, that they could not see the non-colour cues at all. Visually broken, semantically fine, invisible to every automated layer I run. The fix — have the query set a custom property on the element and let the pseudo-element render that — is now taught in the site's own snippet for the feature.

Caught by visitors, after shipping.

A loading spinner and the timeline's watermark years derived their colour from the border token rather than a text token, so on some seed combinations they nearly disappeared. The contrast maths was never wrong; I had wired two elements around it. Somebody said "I can barely see this," which is a bug report no scanner produces.

And this, from a keyboard user: after tabbing deep into a long chapter, getting back to navigation meant tabbing through everything again. That is not a WCAG failure. It is friction that a checklist will never surface and a human feels within ten seconds. Every chapter now ends with a back-to-navigation link, pinned by e2e.

The uncomfortable arithmetic

Line the inventory up and it makes an argument I did not set out to make.

If my pipeline had been "run axe in CI" — which is what a great many pipelines are — every finding in the last two sections ships. The site would have been scanner-clean and still had a dead-end filter for keyboard users, invisible state cues for every Safari visitor, a phantom focus ring on iOS, and washed-out decorative elements on several themes.

None of those layers is redundant with another. What each one misses, another one catches, and the only way to find that out is to run all of them and see which findings overlap. On this site, almost none did.

Where this actually stands

The suite is green today across three engines. Worth being precise about what that means: the failures I have already met cannot come back. It is a regression net, not a certificate, and the site is very much still a work in progress. I would rather publish that sentence than a badge.

The site is also the demo. Every claim above is running code you can poke at, including "break this rule" toggles on the WCAG criteria so you can feel what each one prevents.

GitHub logo ThomasSweet / a11y-foundation

An accessibility-first styling playground — how much of accessibility the modern web platform handles natively, with little to no JavaScript. WCAG rules you can break, cutting-edge CSS showcases, tokens and cascade layers.

Built in, not bolted on — with 'bolted on' struck through

Accessible by default

CI Live site WCAG License: MIT

How much of accessibility does the modern web platform handle natively — with little to no JavaScript? This site is that question, answered as one argument in four parts: what the standard (WCAG) asks for, the craft of meeting it with modern CSS and HTML, what cutting-edge CSS makes possible next, and the proof that it holds up.

Underneath it is an accessibility-first styling foundation — SCSS mixins design tokens, and a cascade-layer architecture — where components adapt to user preferences (reduced motion, high contrast, forced colors, dark mode reduced transparency) and input capabilities (hover, touch) by default, with the cascade doing the work instead of !important.

Note

The design vote is settled. The blueprint restructure — an overview hub plus four chapter pages, wearing a technical-drawing look — won the review round and is now the live design. The previous single-page design is kept under…

If you find a barrier I missed, the issues tab is open and that is a genuinely welcome outcome. Composition bugs like the one at the top of this post are the hardest class to find alone, because they need someone who did not write both features to try using them at the same time.

Top comments (0)