DEV Community

Thomas Sweet
Thomas Sweet

Posted on Edited on

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.

I cannot settle that figure and this article does not try. What I can do is show you where the line falls and why, on a page where I know every answer in advance. On my site there is a page I keep deliberately broken: a mock release page for my band's EP, where the band is real and the page is not its website. It carries twelve WCAG barriers that I planted on purpose. It is noindexed, and the answer key is published beside it. CI runs axe against it on every push, and the result is stable: two violations, image-alt and color-contrast. The other ten pass.

That gap is what the page is for. It is not a complaint about axe, and it is not a coverage statistic either: twelve barriers picked because they need different tools to catch is a rigged sample, and I would not read a percentage off it. What the page is good for is the question underneath. When a scanner stays quiet about a real barrier, what exactly stopped it from speaking?

There are two different answers, and telling them apart is most of what I learned building the thing.

Everything below is checked against axe-core 4.12.1, the version this pipeline resolves. Rule sets move between versions, so if yours pins something else, treat the specifics as things to verify rather than things to take from me.

The setup

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 ordinary one you would wire into any pipeline: axe-core through @axe-core/playwright, scoped by tag.

const tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']
Enter fullscreen mode Exit fullscreen mode

Some numbers are worth having in hand before the results. axe-core 4.12.1 ships 105 rules, and that tag list selects 70 of them. Fewer than that actually run. axe's default configuration carries tagExclude: ['experimental', 'deprecated'], which benches five of the selected rules tagged experimental (css-orientation-lock, label-content-name-mismatch, p-as-heading, table-fake-caption and td-has-header) and two tagged deprecated (aria-roledescription and audio-caption). One more, target-size, simply ships with enabled: false: a WCAG 2.2 Level AA rule that stays off until you ask for it, and the only rule my wcag22aa tag selects at all. That leaves 62 rules running. At least two of the benched rules are pointed straight at barriers on this page, which is where this article ends up.

The two it reports

The EP cover has no alt attribute at all, which fails 1.1.1 Non-text Content (Level A) and comes back as image-alt. The band's tagline is #a7a7a7 on white, which computes to 2.4:1 against a 4.5:1 minimum and fails 1.4.3 Contrast (Minimum) (Level AA) as color-contrast.

Neither result needs an opinion. One is a missing attribute, the other is arithmetic on two colour values. A rule settles both from the DOM and the computed styles without reference to what the page is for or what its author meant. Which is worth saying plainly, because it is the whole of what a rule is: a predicate a machine can evaluate against a node with certainty.

Hold on to "with certainty". It is the load-bearing part.

Reason one: there is no predicate

Most of the silent barriers are silent because the question they raise is not a question about the document.

The alt text that lies

The second image is a band photo with alt="IMG_2047.jpg". image-alt passes it, correctly: an alt attribute is present and non-empty, which is all that rule claims to check.

To flag it you would have to know what the photograph shows and whether those words describe it. That is not a property of the DOM. It is a fact about the world, and no amount of rule authoring gets you there.

The outcome is worth sitting with for a second. When an image has no alt attribute at all, screen readers commonly fall back to announcing the file name. When it has alt="IMG_2047.jpg", they announce the file name because you asked them to. Two barriers that land on the user in nearly the same way, reported as a violation and a pass.

The div that dresses as a button

The page's primary call to action is a div:

<div class="broken-page-listen">Listen now</div>
Enter fullscreen mode Exit fullscreen mode

styled to look exactly like a button. It is not focusable, not operable by keyboard, and announces as nothing in particular, so it fails 2.1.1 Keyboard (Level A) and 4.1.2 Name, Role, Value (Level A) on the most important control on the page.

Nothing matches it. Not one of the 105 rules, experimental ones included. The rules that police buttons apply to elements that admit to being buttons, through their tag or an explicit role, and a generic element containing text is indistinguishable from a badge, a caption, or a stray line of copy.

To be precise about what "invisible" means here, because it is easy to overstate: the div is in the accessibility tree. It arrives as a generic node carrying its text, so a screen reader user browsing linearly does hear "Listen now". What is missing is a role and a place in the focus order, so it never turns up in the buttons list, never gets tabbed to, and gets filtered straight out by anything selecting for interactive elements.

There is a detail here I did not expect. axe does have a rule for roughly this mistake, focus-order-semantics, and its selector includes div. It could never see mine, though, because its matcher only picks up elements that have been inserted into the focus order, which in practice means something carrying a tabindex. And the rule is itself tagged both best-practice and experimental, so a WCAG-tag-scoped run like this one leaves it on the bench twice over. Give the div tabindex="0" and you make it half usable, and in the same movement you make it visible to the rule, in any run configured to include it. Leave it entirely broken and it stays beneath notice.

Opting out of semantics opts you out of scrutiny. Doing half the job is what puts you back in view.

The label that is not a label

The mailing-list form:

<input type="email" placeholder="Your email address" />
Enter fullscreen mode Exit fullscreen mode

The field's only label is its placeholder, which fails 3.3.2 Labels or Instructions (Level A): its one instruction disappears the moment you act on it.

I assumed for a while that axe was simply not looking. It is looking, and it has decided. The label rule enumerates the ways a control can acquire a name, and placeholder is on the list by name:

any: [
  'implicit-label', 'explicit-label', 'aria-label', 'aria-labelledby',
  { id: 'non-empty-title', options: { attribute: 'title' } },
  { id: 'non-empty-placeholder', options: { attribute: 'placeholder' } },
  'presentational-role'
]
Enter fullscreen mode Exit fullscreen mode

That is not carelessness. placeholder really does participate in the accessible name computation for text inputs, at the very bottom of the fallback chain, so the field genuinely has an accessible name. The rule checks whether a name exists. One does.

What a rule cannot weigh is what kind of name it is: drawn faint by the browser, gone the moment you type, never a label element at all. The field is named on paper and unlabelled in practice, and only the paper is computable.

There is a best-practice rule in the neighbourhood, label-title-only, and it does not help either. Its check fires only when a control's name comes from title or aria-describedby, and a placeholder-only input has neither. No ruleset axe ships reports this field, at any tag scope, experimental included.

The colour that carries the meaning

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 what 1.4.1 Use of Color (Level A) asks, and answering it means reading the page the way a person does: knowing what the rows are for, then asking what survives when the colour is taken away. Turn on a vision-deficiency emulation in DevTools and watch the distinction thin out.

No rule fires because there is no rule to fire. "Meaning carried by colour alone" is not a property of a node.

While I am on that row: the German word sits in an English document with no lang="de" around it, which is 3.1.2 Language of Parts at Level AA. valid-lang checks the lang attributes that are there, not the ones that should be.

Reason two: there is a rule, and axe ships it switched off

The second reason is a different animal, and it is the one that changed how I read scan results.

The button whose visible label is not its name

The subscribe button reads "Join the mailing list" and carries aria-label="subscribe":

<button type="button" aria-label="subscribe">Join the mailing list</button>
Enter fullscreen mode Exit fullscreen mode

so the words a user can see appear nowhere in the control's accessible name. Someone using voice control who says "click join the mailing list" gets nothing. That fails 2.5.3 Label in Name (Level A, added in WCAG 2.1).

Unlike everything above, axe has a rule for it: label-content-name-mismatch, tagged wcag21a and wcag253, both of which are inside my tag scope. It still never fired, because the rule is also tagged experimental, and the default tagExclude keeps every experimental rule out of the run. withTags chooses among the rules a run will consider. It does not overrule the exclusion.

That is a defensible default. Matching visible text against an accessible name is heuristic work, and heuristics generate false positives, and false positives in a build gate get the gate removed. But it does mean "we run axe" describes a configuration rather than a result, and two pipelines that both say it can disagree about this page.

The tracklist with no headers

Same story, different shape. The tracklist is six rows built entirely of td cells with no th anywhere, so assistive technology cannot answer "which column am I in?". That fails 1.3.1 Info and Relationships (Level A).

A headerless table is legal HTML, and the genuine difficulty is that a layout table which never needed headers and a data table that lost them look the same in the markup. The difference is intent.

axe takes a run at it anyway, and how it does that is the interesting bit. td-has-header is aimed at exactly this barrier, and it gets there by guessing: a matcher decides whether the element looks like a data table at all, and the rule only applies to tables of at least three rows and three columns. The tracklist, six rows of three cells each, qualifies; enable the rule and it fires. So the rule exists, it encodes a heuristic for intent, and it sits on the experimental bench for the same reason as the one above.

Line the two reasons up and the shape is hard to miss. The rules axe runs by default are the ones that can be decided from the document with certainty. The rules it withholds are the ones that have to guess at intent. The barriers with no rule at all are the ones where there is nothing to guess with. axe's own packaging draws the same line this page is about, which is a better argument for that line than any percentage I could quote at you.

Same level, different bug

One calibration before the tooling, 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 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',
])
Enter fullscreen mode Exit fullscreen mode

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 has to be undone, or axe got better, which is worth knowing the day it happens. That second one is not hypothetical. Every benched rule above is a candidate for graduating into the default set, and this page will tell me the morning it does.

Go and look

The audit room holds the broken page as an inert half-scale preview, with one disclosure per barrier: a hint first, the full answer a level deeper. Open the broken page at full scale in its own tab and hunt with the Tab key, the heading list and the accessibility pane before you touch the answers. Several of the twelve never came up in this article at all.

Then run your own scan against it. If your axe reports something other than those two, I would like to hear about it, because that is the interesting failure.

Edited after publishing: the original said a scanner cannot distinguish a layout table from a data table. axe does try, via an experimental rule it ships switched off. The corrected section is above.

Top comments (2)

Collapse
 
a11ysolutions profile image
A11ySolutions

That unclickable div isn't just invisible to Axe, it's invisible to anything reading the accessibility tree, screen readers and AI agents included. We've run into the exact same failure pattern testing whether AI agents can complete tasks in real booking flows: the agent finds the page fine, but if the main CTA never resolves to an actual control with a name and role, there's simply nothing for it to act on.

Collapse
 
thomas_sweet profile image
Thomas Sweet

Hadn't thought about agents at all when I wrote this, and now it's bothering me in a good way. Booking flows are exactly where I'd expect this to bite and I have nothing but guesses.

What's the failure mode at your end? Does the agent stall, or does it grab something adjacent and report success? The second one worries me more, because it's the same trap as the band photo further down that page: alt="IMG_2047.jpg" passes image-alt and the user is still stuck.