Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Twelve planted accessibility barriers. axe found two.

You have probably heard the figure: automated accessibility tools catch only around a third of the problems on a page. It turns up in talks, vendor decks, and arguments on both sides, almost always without its receipts.

You have probably heard the figure: automated accessibility tools catch only around a third of the problems on a page. It turns up in talks, vendor decks, and arguments on both sides, almost always without its receipts.

Here is a version you can check. On my site there is a page I keep deliberately broken: a mock release page for my band's EP (the band is real, the page is not its website), seeded with twelve real WCAG barriers, noindexed, with the answer key published beside it. CI runs axe against it and pins the result: exactly two violations, image-alt and color-contrast.

The other ten pass. That is not a defect in axe. Nearly all of them are broken in ways no rule can see, and the interesting part is why.

The experiment

The broken page is a self-contained HTML document with deliberately ordinary CSS: no framework, no design system, no safety nets. The isolation is load-bearing: an earlier version lived inside the main site, where a global reduced-motion rule and the preferences layer's focus ring kept quietly repairing planted barriers. A separate document is the only boundary CSS actually respects.

The scan is the standard one you would wire into any pipeline: axe-core via @axe-core/playwright, scoped to the A and AA WCAG tags:

const tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']

The two catches are the two you would predict. The EP cover has no alt attribute at all, which fails 1.1.1 Non-text Content (Level A) as image-alt. The band's tagline is #a7a7a7 grey on white, well short of the 4.5:1 minimum, which fails 1.4.3 Contrast (Minimum) (Level AA) as color-contrast. A missing attribute and a failed ratio: both readable straight off the DOM and a colour value, no understanding required.

One caveat: two of twelve is not a coverage benchmark. These twelve were chosen to need different tools to catch, so the ratio is engineered. The boundary is not, and which barriers fall on which side of it holds for every page you will ever scan.

A rule cannot ask whether the alt is lying

The second image on the page is a band photo with alt="IMG_2047.jpg". The image-alt rule passes it, and it should: an alt attribute exists, and no rule in axe's default rulesets judges whether the text is true. To flag it, a scanner would need to know what the photo shows and whether the words describe it. That is not a property of the DOM; it is a fact about the world.

To a screen reader user, a camera filename is worth exactly as much as the missing alt one screen up. The scanner reports them as opposite results: one violation, one pass.

A rule cannot know what you meant to build

The page's primary call to action is <div class="broken-page-listen">Listen now</div>, styled to look exactly like a button. It is not focusable, not operable by keyboard, and announces as nothing in particular, failing 2.1.1 Keyboard (Level A) and 4.1.2 Name, Role, Value (Level A) on the most important control on the page.

axe says nothing, and structurally it is right to: this is a generic element containing text, indistinguishable from a badge or a caption. The rules that police buttons apply to elements that admit to being buttons, through the tag or an explicit role. A div that only dresses as one is never inspected. Opting out of semantics also opts you out of scrutiny.

The tracklist is the same story in table form: six rows built entirely of td cells, no th anywhere, so assistive technology cannot answer "which column am I in?". That fails 1.3.1 Info and Relationships (Level A), but a headerless table is legal HTML, and a scanner cannot tell a layout table that never needed headers from a data table that lost them. The difference is intent, and intent is not in the DOM.

Technically named is all a rule can verify

The mailing-list form hosts two barriers in two elements:

<input type="email" placeholder="Your email address" />
<button type="button" aria-label="subscribe">Join the mailing list</button>

The input's only label is its placeholder, which fails 3.3.2 Labels or Instructions (Level A): the field's one instruction disappears the moment you act on it. No scanner objects, and the mechanics are instructive. placeholder participates in the accessible name computation for text inputs, at the very bottom of the fallback chain, so the field genuinely has an accessible name. A rule can verify that a name exists. It cannot weigh what kind of name it is: drawn faint by the browser, gone the moment you type, never a real label element at all. The field is named on paper and unlabelled in practice, and only the paper is computable.

Some judgements need the whole page

In the tour list, sold-out dates carry the word "ausverkauft" in red, and that red is the only thing separating a dead entry from the "Tickets" action sitting in the same position one row down. Whether a colour is reinforcement or the sole carrier of meaning is exactly the question 1.4.1 Use of Color (Level A) asks, and answering it means reading the page as a person: knowing what the rows are for, asking what survives when the colour is taken away. Flip on a vision-deficiency emulation and watch the distinction thin out. No rule fires, because there is no rule: "meaning carried by colour alone" is not a predicate over nodes.

The third catch that never fired

I predicted three. The subscribe button above displays "Join the mailing list" but carries aria-label="subscribe", so the words a user can see are nowhere in the control's accessible name. A voice-control user who says "click join the mailing list" gets nothing; that fails 2.5.3 Label in Name (Level A, added in WCAG 2.1). And unlike everything above, axe has a rule for it: label-content-name-mismatch.

It never fired. The rule sits in axe's experimental ruleset, which ships disabled by default, and a tag-scoped run like the one above does not wake it. That is a defensible default, since matching visible text against accessible names is heuristic territory. But it means even "what the scanner checks" has a configuration story: two pipelines that both say "we run axe" can disagree about this page.

Same level, different bug

One more calibration, because conformance levels invite a misreading. Level A does not mean "hurts more than AA": the level grades how foundational a requirement is, not how much a given instance harms. The unpressable div and the filename alt both fail at Level A. One is the page's primary call to action being absent for every keyboard and screen reader user; the other is a band photo at the foot of the page announcing itself as a camera file. Same level, not remotely the same bug. Prioritisation needs human judgement for the same reason detection did.

The test that fails when the bug gets fixed

A fixture like this earns its keep only while it stays exactly as broken as documented, so the suite pins it from both sides. The scan of the broken page does not assert "no unexpected violations"; it asserts that the violation set equals the documented pair:

const results = await new AxeBuilder({ page }).withTags(tags).analyze()

expect(results.violations.map((v) => v.id).sort()).toEqual([
  'color-contrast',
  'image-alt',
])

A companion test scans the surrounding page with the fixture excluded, via .exclude('#broken-page'), and asserts zero violations. Between them: nothing outside the frame may be broken, and nothing inside it may heal. If a refactor accidentally repairs a planted barrier, CI goes red, because a teaching fixture that quietly heals teaches a lie. It is the most cheerful red build I own: a failure means either the page got better, which must be undone, or axe got better, which is worth knowing the day it happens.

Try the hunt

The audit room holds the broken page as an inert half-scale preview and lists one disclosure per barrier: a hint first, the full answer one level deeper. Open the broken page at full scale in its own tab from there and hunt the silent ten with the Tab key, the heading list, and the accessibility pane before touching the answers; several of the twelve never appeared in this article at all, and your own axe or Lighthouse run against the standalone page can check my arithmetic.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.