<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Thomas Sweet</title>
    <description>The latest articles on DEV Community by Thomas Sweet (@thomas_sweet).</description>
    <link>https://dev.to/thomas_sweet</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F290476%2F5a574949-d970-46a6-80b4-b4f33eac456b.jpeg</url>
      <title>DEV Community: Thomas Sweet</title>
      <link>https://dev.to/thomas_sweet</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thomas_sweet"/>
    <language>en</language>
    <item>
      <title>Twelve planted accessibility barriers. axe found two.</title>
      <dc:creator>Thomas Sweet</dc:creator>
      <pubDate>Sat, 08 Aug 2026 21:16:46 +0000</pubDate>
      <link>https://dev.to/thomas_sweet/twelve-planted-accessibility-barriers-axe-found-two-2gnp</link>
      <guid>https://dev.to/thomas_sweet/twelve-planted-accessibility-barriers-axe-found-two-2gnp</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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, &lt;code&gt;image-alt&lt;/code&gt; and &lt;code&gt;color-contrast&lt;/code&gt;. The other ten pass.&lt;/p&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;There are two different answers, and telling them apart is most of what I learned building the thing.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The scan is the ordinary one you would wire into any pipeline: axe-core through &lt;code&gt;@axe-core/playwright&lt;/code&gt;, scoped by tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag2a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag2aa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag21a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag21aa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcag22aa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;tagExclude: ['experimental', 'deprecated']&lt;/code&gt;, which benches five of the selected rules tagged &lt;code&gt;experimental&lt;/code&gt; (&lt;code&gt;css-orientation-lock&lt;/code&gt;, &lt;code&gt;label-content-name-mismatch&lt;/code&gt;, &lt;code&gt;p-as-heading&lt;/code&gt;, &lt;code&gt;table-fake-caption&lt;/code&gt; and &lt;code&gt;td-has-header&lt;/code&gt;) and two tagged &lt;code&gt;deprecated&lt;/code&gt; (&lt;code&gt;aria-roledescription&lt;/code&gt; and &lt;code&gt;audio-caption&lt;/code&gt;). One more, &lt;code&gt;target-size&lt;/code&gt;, simply ships with &lt;code&gt;enabled: false&lt;/code&gt;: a WCAG 2.2 Level AA rule that stays off until you ask for it, and the only rule my &lt;code&gt;wcag22aa&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two it reports
&lt;/h2&gt;

&lt;p&gt;The EP cover has no &lt;code&gt;alt&lt;/code&gt; attribute at all, which fails &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html" rel="noopener noreferrer"&gt;1.1.1 Non-text Content&lt;/a&gt; (Level A) and comes back as &lt;code&gt;image-alt&lt;/code&gt;. The band's tagline is &lt;code&gt;#a7a7a7&lt;/code&gt; on white, which computes to 2.4:1 against a 4.5:1 minimum and fails &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html" rel="noopener noreferrer"&gt;1.4.3 Contrast (Minimum)&lt;/a&gt; (Level AA) as &lt;code&gt;color-contrast&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Hold on to "with certainty". It is the load-bearing part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason one: there is no predicate
&lt;/h2&gt;

&lt;p&gt;Most of the silent barriers are silent because the question they raise is not a question about the document.&lt;/p&gt;

&lt;h3&gt;
  
  
  The alt text that lies
&lt;/h3&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;alt="IMG_2047.jpg"&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The div that dresses as a button
&lt;/h3&gt;

&lt;p&gt;The page's primary call to action is a div:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"broken-page-listen"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Listen now&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;styled to look exactly like a button. It is not focusable, not operable by keyboard, and announces as nothing in particular, so it fails &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html" rel="noopener noreferrer"&gt;2.1.1 Keyboard&lt;/a&gt; (Level A) and &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html" rel="noopener noreferrer"&gt;4.1.2 Name, Role, Value&lt;/a&gt; (Level A) on the most important control on the page.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;role&lt;/code&gt;, and a generic element containing text is indistinguishable from a badge, a caption, or a stray line of copy.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;There is a detail here I did not expect. axe does have a rule for roughly this mistake, &lt;code&gt;focus-order-semantics&lt;/code&gt;, and its selector includes &lt;code&gt;div&lt;/code&gt;. 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 &lt;code&gt;tabindex&lt;/code&gt;. And the rule is itself tagged both &lt;code&gt;best-practice&lt;/code&gt; and &lt;code&gt;experimental&lt;/code&gt;, so a WCAG-tag-scoped run like this one leaves it on the bench twice over. Give the div &lt;code&gt;tabindex="0"&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Opting out of semantics opts you out of scrutiny. Doing half the job is what puts you back in view.&lt;/p&gt;

&lt;h3&gt;
  
  
  The label that is not a label
&lt;/h3&gt;

&lt;p&gt;The mailing-list form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Your email address"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The field's only label is its placeholder, which fails &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html" rel="noopener noreferrer"&gt;3.3.2 Labels or Instructions&lt;/a&gt; (Level A): its one instruction disappears the moment you act on it.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;any&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;implicit-label&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;explicit-label&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-label&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-labelledby&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;non-empty-title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;attribute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;non-empty-placeholder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;attribute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;placeholder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;presentational-role&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not carelessness. &lt;code&gt;placeholder&lt;/code&gt; really does participate in the &lt;a href="https://developer.mozilla.org/en-US/docs/Glossary/Accessible_name" rel="noopener noreferrer"&gt;accessible name computation&lt;/a&gt; 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.&lt;/p&gt;

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

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

&lt;h3&gt;
  
  
  The colour that carries the meaning
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Whether a colour is reinforcement or the sole carrier of meaning is exactly what &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html" rel="noopener noreferrer"&gt;1.4.1 Use of Color&lt;/a&gt; (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.&lt;/p&gt;

&lt;p&gt;No rule fires because there is no rule to fire. "Meaning carried by colour alone" is not a property of a node.&lt;/p&gt;

&lt;p&gt;While I am on that row: the German word sits in an English document with no &lt;code&gt;lang="de"&lt;/code&gt; around it, which is &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html" rel="noopener noreferrer"&gt;3.1.2 Language of Parts&lt;/a&gt; at Level AA. &lt;code&gt;valid-lang&lt;/code&gt; checks the lang attributes that are there, not the ones that should be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason two: there is a rule, and axe ships it switched off
&lt;/h2&gt;

&lt;p&gt;The second reason is a different animal, and it is the one that changed how I read scan results.&lt;/p&gt;

&lt;h3&gt;
  
  
  The button whose visible label is not its name
&lt;/h3&gt;

&lt;p&gt;The subscribe button reads "Join the mailing list" and carries &lt;code&gt;aria-label="subscribe"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"subscribe"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Join the mailing list&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html" rel="noopener noreferrer"&gt;2.5.3 Label in Name&lt;/a&gt; (Level A, added in WCAG 2.1).&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tracklist with no headers
&lt;/h3&gt;

&lt;p&gt;Same story, different shape. The tracklist is six rows built entirely of &lt;code&gt;td&lt;/code&gt; cells with no &lt;code&gt;th&lt;/code&gt; anywhere, so assistive technology cannot answer "which column am I in?". That fails &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html" rel="noopener noreferrer"&gt;1.3.1 Info and Relationships&lt;/a&gt; (Level A).&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;axe takes a run at it anyway, and how it does that is the interesting bit. &lt;code&gt;td-has-header&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same level, different bug
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that fails when the bug gets fixed
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AxeBuilder&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;withTags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;color-contrast&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image-alt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A companion test scans the surrounding page with the fixture excluded, via &lt;code&gt;.exclude('#broken-page')&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go and look
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://accessible-by-default.dev/audit-room.html" rel="noopener noreferrer"&gt;audit room&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>testing</category>
      <category>webdev</category>
      <category>html</category>
    </item>
    <item>
      <title>prefers-color-scheme doesn't ask your OS. It asks your browser.</title>
      <dc:creator>Thomas Sweet</dc:creator>
      <pubDate>Sat, 08 Aug 2026 18:45:19 +0000</pubDate>
      <link>https://dev.to/thomas_sweet/prefers-color-scheme-doesnt-ask-your-os-it-asks-your-browser-3371</link>
      <guid>https://dev.to/thomas_sweet/prefers-color-scheme-doesnt-ask-your-os-it-asks-your-browser-3371</guid>
      <description>&lt;p&gt;My OS was in light mode. GitHub was set to sync with the system. &lt;a href="https://github.com/ThomasSweet/a11y-foundation" rel="noopener noreferrer"&gt;The README's images&lt;/a&gt; were dark.&lt;/p&gt;

&lt;p&gt;I checked the srcsets. I suspected GitHub's HTML sanitizer. I cleared caches I had no reason to believe were involved. Then I opened the page in a private window and the images were correct, which ruled out my markup entirely and left me with no suspects at all.&lt;/p&gt;

&lt;p&gt;The override was in my own browser. Chrome's appearance setting was pinned to Dark, and had been for months. Not the OS, not GitHub, not the markup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain
&lt;/h2&gt;

&lt;p&gt;I had been thinking about dark mode as one setting. It is a chain of at least three, and each link can silently override the one above it while reporting nothing to the one below.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj4da5npo0k6e1412qcuk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj4da5npo0k6e1412qcuk.png" alt="The colour scheme chain: OS setting flows into the browser's appearance setting, which produces prefers-color-scheme, which the page consumes; any layer can pin a value and downstream layers never know" width="800" height="319"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The operating system&lt;/strong&gt; has a colour scheme setting. This is the one everybody means by "follows your system".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The browser&lt;/strong&gt; defaults to following the OS, and can pin its own value instead. Chrome's appearance setting has Light and Dark options that override the system for every page you visit. This layer's output is what &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme" rel="noopener noreferrer"&gt;&lt;code&gt;prefers-color-scheme&lt;/code&gt;&lt;/a&gt; reports — MDN is precise about it, if you read closely: the preference comes from "an operating system setting &lt;strong&gt;or&lt;/strong&gt; a user agent setting". CSS never talks to your OS. It talks to this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The site&lt;/strong&gt; can follow the browser, via &lt;code&gt;light-dark()&lt;/code&gt; or media queries, or run its own theme control on top. GitHub does the latter: its theme preference drives the page chrome, and only agrees with your images' media queries when it is set to sync.&lt;/p&gt;

&lt;p&gt;My machine had layer 2 pinned. Every media query on every site I visited was being told "dark" while the OS said light, and nothing anywhere indicates that an override is active. If a user has ever told you your dark mode toggle does nothing, there is a fair chance nothing was broken except this chain.&lt;/p&gt;

&lt;p&gt;The debugging order that would have saved me two hours: check a private window first, which isolates site-level state, then walk the chain upward from the page rather than downward from the OS. The layer you are least likely to suspect is the one in the middle, because you configured it once and forgot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I had shipped the wrong claim
&lt;/h2&gt;

&lt;p&gt;My README stated that the images "follow your OS colour scheme".&lt;/p&gt;

&lt;p&gt;That is not what they do. They follow &lt;code&gt;prefers-color-scheme&lt;/code&gt;, which usually reflects the OS and, on my own machine at that moment, did not. The claim was false on the very machine I wrote it on.&lt;/p&gt;

&lt;p&gt;For a repository whose subject is accessibility education, naming the wrong mechanism is a defect like any other, so it got a commit. The README now says the images follow your colour-scheme preference as reported by your browser, which is usually your OS setting unless the browser overrides it. It also gained the troubleshooting line I wish I had read at the start: if a scheme toggle seems to do nothing, check your browser's own appearance setting.&lt;/p&gt;

&lt;p&gt;Precision about mechanisms is not pedantry here. Documentation that names the wrong layer sends the next person to debug the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works in a README
&lt;/h2&gt;

&lt;p&gt;Worth stating plainly, because the constraints surprised me and the capability is better than I expected.&lt;/p&gt;

&lt;p&gt;GitHub's sanitizer allows &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt;, which is the whole scheme-swap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;picture&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;media=&lt;/span&gt;&lt;span class="s"&gt;"(prefers-color-scheme: dark)"&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"docs/screenshots/hub-dark.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"The overview hub"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"docs/screenshots/hub-light.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/picture&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;And an SVG referenced as a plain image still evaluates its own internal CSS, including media queries. One file can adapt to colour scheme &lt;em&gt;and&lt;/em&gt; gate its animation behind &lt;code&gt;prefers-reduced-motion&lt;/code&gt;. It cannot load external resources, so fonts have to be system stacks and everything has to be self-contained, but within that boundary it is a real stylesheet.&lt;/p&gt;

&lt;p&gt;One consequence worth thinking through: an animation inside an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; has nowhere to put a pause button. WCAG expects moving content to be pausable, and an image cannot offer controls. So the banner's animation is finite by design. It draws, holds, replays twice, then settles permanently into its end state. Reduced-motion users get the settled state with no motion at all, through the same media query that handles the colours. When you cannot give motion an off switch, the alternative is giving it an ending.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two sharp edges in the same file
&lt;/h2&gt;

&lt;p&gt;Neither of these is about colour schemes. Both cost me hours in the same banner, and both are the kind of thing you only meet by walking into them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never put an angle bracket inside an SVG &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; element.&lt;/strong&gt; I wrote a CSS comment that mentioned an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag — literally, inside the stylesheet. In XML that &lt;code&gt;&amp;lt;&lt;/code&gt; starts markup, so the parser silently truncated the stylesheet at that point and discarded everything after it.&lt;/p&gt;

&lt;p&gt;The consequences were disproportionate to the typo. The banner's animation was dead in every version I had shipped. Every fix I tried was invisible, because the code I was fixing sat after the comment and never ran. And there was no error, in any console, at any point — a truncated stylesheet is not malformed, it is just short. I was debugging keyframes that the parser had never seen. The comment now says "an image", and the file carries a warning for future me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;textLength&lt;/code&gt; needs &lt;code&gt;lengthAdjust="spacing"&lt;/code&gt;.&lt;/strong&gt; The banner's headline has to fit its plate on macOS, Windows and Linux system fonts, so &lt;code&gt;textLength&lt;/code&gt; pins the width. But &lt;code&gt;lengthAdjust&lt;/code&gt; decides what absorbs the difference: &lt;code&gt;spacingAndGlyphs&lt;/code&gt; stretches the letterforms themselves, which came out around 25 percent in my case and reads as a strange skew rather than a font substitution. &lt;code&gt;spacing&lt;/code&gt; flexes only the gaps and leaves the glyphs alone. Measure the natural width on your reference platform, set &lt;code&gt;textLength&lt;/code&gt; to that, and declare &lt;code&gt;lengthAdjust&lt;/code&gt; explicitly rather than trusting whatever you inherit — it is the difference between a banner that adapts and one that looks subtly wrong on every platform except the one you designed it on.&lt;/p&gt;
&lt;h2&gt;
  
  
  Go and flip a setting
&lt;/h2&gt;

&lt;p&gt;The adaptive banner, the scheme-swapping screenshots and the corrected wording are all &lt;a href="https://github.com/ThomasSweet/a11y-foundation" rel="noopener noreferrer"&gt;on the repo&lt;/a&gt; now. Change your browser's appearance setting while looking at it — not your OS. That is the demo, and if the images change while your system stays put, you have just watched layer 2 do its thing.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ThomasSweet" rel="noopener noreferrer"&gt;
        ThomasSweet
      &lt;/a&gt; / &lt;a href="https://github.com/ThomasSweet/a11y-foundation" rel="noopener noreferrer"&gt;
        a11y-foundation
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      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.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/ThomasSweet/a11y-foundation/docs/banner.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FThomasSweet%2Fa11y-foundation%2FHEAD%2Fdocs%2Fbanner.svg" alt="Built in, not bolted on — with 'bolted on' struck through" width="1080"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Accessible by default&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/ThomasSweet/a11y-foundation/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/ThomasSweet/a11y-foundation/actions/workflows/ci.yml/badge.svg" alt="CI"&gt;&lt;/a&gt;
&lt;a href="https://accessible-by-default.dev/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/d1e138eac32100e698a6eab425cac007e2f930ca99ff897c55a23bd96aec7f92/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6976652d61636365737369626c652d2d62792d2d64656661756c742e6465762d323536336562" alt="Live site"&gt;&lt;/a&gt;
&lt;a href="https://www.w3.org/TR/WCAG22/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/32a0ace5dd0aa27a0a392f7060354a71ce15bed55bc25c10d3f5622f05c20f43/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f574341475f322e325f41412d7461726765742d313661333461" alt="WCAG"&gt;&lt;/a&gt;
&lt;a href="https://github.com/ThomasSweet/a11y-foundation/./LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/9c07affde96ff6cfbd11de8b722af95eec32e3a544b09bff2855a3191aef309d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d386138613861" alt="License: MIT"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;How much of accessibility does the modern web platform handle &lt;strong&gt;natively&lt;/strong&gt; —
with little to no JavaScript? This site is that question, answered as one
argument in four parts: what &lt;strong&gt;the standard&lt;/strong&gt; (WCAG) asks for, &lt;strong&gt;the craft&lt;/strong&gt;
of meeting it with modern CSS and HTML, what cutting-edge &lt;strong&gt;CSS&lt;/strong&gt; makes
possible next, and &lt;strong&gt;the proof&lt;/strong&gt; that it holds up.&lt;/p&gt;
&lt;p&gt;Underneath it is an accessibility-first styling foundation — SCSS mixins
design tokens, and a cascade-layer architecture — where components adapt to
&lt;strong&gt;user preferences&lt;/strong&gt; (reduced motion, high contrast, forced colors, dark mode
reduced transparency) and &lt;strong&gt;input capabilities&lt;/strong&gt; (hover, touch) by default,
with the cascade doing the work instead of &lt;code&gt;!important&lt;/code&gt;.&lt;/p&gt;
&lt;div class="markdown-alert markdown-alert-note"&gt;
&lt;p class="markdown-alert-title"&gt;Note&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The design vote is settled.&lt;/strong&gt; 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…&lt;/p&gt;
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ThomasSweet/a11y-foundation" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;a href="https://accessible-by-default.dev/" rel="noopener noreferrer"&gt;The site&lt;/a&gt; the banner belongs to has since been rebuilt as an overview hub plus four chapter pages, making the same argument with rather more room for it. If you find a barrier anywhere in it, the issues tab is open.&lt;/p&gt;

</description>
      <category>css</category>
      <category>github</category>
      <category>webdev</category>
      <category>a11y</category>
    </item>
    <item>
      <title>My zero-JS page transitions flashed white. Two bugs, two causes, one blend mode I'd never heard of.</title>
      <dc:creator>Thomas Sweet</dc:creator>
      <pubDate>Fri, 31 Jul 2026 19:48:46 +0000</pubDate>
      <link>https://dev.to/thomas_sweet/my-zero-js-page-transitions-flashed-white-two-bugs-two-causes-one-blend-mode-id-never-heard-of-5g77</link>
      <guid>https://dev.to/thomas_sweet/my-zero-js-page-transitions-flashed-white-two-bugs-two-causes-one-blend-mode-id-never-heard-of-5g77</guid>
      <description>&lt;p&gt;&lt;strong&gt;The short version, if you arrived here from a search:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your automation browser may not run cross-document view transitions at all. Check that before you debug anything else.&lt;/li&gt;
&lt;li&gt;If you replace the default transition animation, you inherit &lt;code&gt;mix-blend-mode: plus-lighter&lt;/code&gt;, which was chosen for the animation you just deleted. That is your brightness pulse.&lt;/li&gt;
&lt;li&gt;A hard white blink &lt;em&gt;before&lt;/em&gt; the transition is not the transition. It is an unstyled frame of the incoming document painting the UA default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The long version follows, because the reasoning is more useful than the fixes.&lt;/p&gt;




&lt;p&gt;My site &lt;a href="https://accessible-by-default.dev/" rel="noopener noreferrer"&gt;accessible-by-default.dev&lt;/a&gt; is a multi-page app on purpose. Real page loads mean screen readers announce the new page, focus resets, the URL bar tells the truth, and there is no routing JavaScript to break. It is an overview hub plus four chapter pages, and I wanted moving between them to feel calm: the frame stays put, the content quietly fades.&lt;/p&gt;

&lt;p&gt;The platform hands you exactly this. One &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@view-transition" rel="noopener noreferrer"&gt;at-rule&lt;/a&gt; in both documents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@view-transition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;navigation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The browser snapshots the old page, loads the new one, and animates between them, across a real navigation, with no JavaScript.&lt;/p&gt;

&lt;p&gt;Then I customised the animation and got flashes. Bright pulses mid-fade in light mode, odd glare in dark mode, and separately a hard white blink on some navigations. I spent a while chasing them as one bug. They were not one bug.&lt;/p&gt;
&lt;h2&gt;
  
  
  The trap that cost me an evening first
&lt;/h2&gt;

&lt;p&gt;I could not reproduce any of it in my e2e setup, for an unhelpful reason: the Chromium build my test runner drives does not execute cross-document view transitions at all. Pages swapped instantly. No snapshots, nothing to observe, nothing to debug.&lt;/p&gt;

&lt;p&gt;I ended up driving my real installed Chrome over the DevTools protocol to see what users see.&lt;/p&gt;

&lt;p&gt;So: if your transition works locally but not in tests, or passes tests but misbehaves locally, verify that your automation browser actually performs cross-document view transitions before you form a single hypothesis about your CSS. Mine did not, and every conclusion I drew from those runs was noise.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug one: the brightness pulse
&lt;/h2&gt;

&lt;p&gt;My custom animation is deliberately simple. The new page renders immediately underneath, and the old page's snapshot fades out on top of it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;::view-transition-new&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;root&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;::view-transition-old&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;root&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.28s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt; &lt;span class="n"&gt;vt-content-fade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;vt-content-fade&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Fade one opaque layer over another opaque layer. There is no frame in that description which should produce something brighter than either page. And yet, a visible brightness pulse, on every navigation.&lt;/p&gt;

&lt;p&gt;The answer is in &lt;a href="https://drafts.csswg.org/css-view-transitions-1/#ua-styles" rel="noopener noreferrer"&gt;the browser's own stylesheet&lt;/a&gt;. The spec places the old and new snapshots inside an isolated group and blends them with &lt;code&gt;mix-blend-mode: plus-lighter&lt;/code&gt;, which &lt;em&gt;adds&lt;/em&gt; the colours of the layers together, weighted by alpha.&lt;/p&gt;

&lt;p&gt;That default is a good decision. In the browser's default cross-fade, the old snapshot's opacity runs 1 to 0 while the new one runs 0 to 1, so the weights always sum to one, and plus-lighter makes the crossfade mathematically seamless: two half-transparent copies of similar content add back up to full brightness instead of dipping grey in the middle.&lt;/p&gt;

&lt;p&gt;But I had replaced the default animation. My new snapshot sits at full opacity for the entire transition, and early in the fade the old snapshot is still nearly opaque on top of it. Plus-lighter adds them. Almost double the light, which on a light theme clips straight to white.&lt;/p&gt;

&lt;p&gt;So the pulse was a blend mode doing precisely what it was told, under an assumption my keyframes had quietly invalidated.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2qazs7c00evurf1e8sm4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2qazs7c00evurf1e8sm4.png" alt="Two snapshot layers being added together by plus-lighter and clipping to white, versus normal painting after the fix" width="800" height="341"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix is to restore normal painting:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;::view-transition-image-pair&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;root&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;isolation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;::view-transition-old&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;root&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
&lt;span class="nd"&gt;::view-transition-new&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;root&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;mix-blend-mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The generalisable version: if you replace the default view-transition animation with anything whose opacities do not sum to one, you have also inherited a blend mode designed for the animation you just deleted.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug two: the hard white blink
&lt;/h2&gt;

&lt;p&gt;After the blend fix, something was still there. A pure white flash on some navigations, and this one was fast enough that I could not tell where in the sequence it sat.&lt;/p&gt;

&lt;p&gt;Stretching the transition to 1.5 seconds settled it: the flash happened first, then the fade played cleanly. It was not part of the transition at all.&lt;/p&gt;

&lt;p&gt;It was the incoming document. During the handoff there can be a painted frame of the new page before its CSS has been applied, and an unstyled document paints the user-agent default, which is white. In dev this was worst, because Vite injects CSS through JavaScript, but a cold navigation in production can catch it too.&lt;/p&gt;

&lt;p&gt;The fix is unglamorous and reliable. Every entry HTML inlines one declaration, so that even a completely unstyled frame paints in the page's background colour rather than white:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;@layer&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--boot-ground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;light-dark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;#f5f5f5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#1a1a1a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--bp-ground&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--boot-ground&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Those two hex values are the light and dark background colours of the blueprint theme — the "ground" the rest of the page sits on. The declaration lives in the lowest cascade layer, so the moment the real stylesheet arrives, the theme's own ground token wins through the &lt;code&gt;var()&lt;/code&gt; fallback chain. Before that moment, the raw frame is already roughly the right colour.&lt;/p&gt;

&lt;p&gt;Two related details if you build this. Give the entry script &lt;code&gt;blocking="render"&lt;/code&gt;, so the first rendered frame — and therefore the snapshot the transition captures — is the full page rather than an empty mount point. And check that your bundler preserves the attribute: Vite dropped it from the built HTML, which took a four-line plugin to put back.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why a flash is worth this much attention
&lt;/h2&gt;

&lt;p&gt;Sudden full-screen luminance changes are not a cosmetic issue. For photosensitive users they are the exact thing &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/three-flashes-or-below-threshold.html" rel="noopener noreferrer"&gt;WCAG's flash criteria&lt;/a&gt; exist to prevent, and a page that blinks bright white between navigations is a worse experience than a page with no transition at all.&lt;/p&gt;

&lt;p&gt;Which is why the whole feature is an enhancement rather than a baseline:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;no-preference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;/* the entire transition setup lives in here */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Reduced-motion users get instant, ordinary navigations. Everyone else gets a 0.28 second content fade on top of real multi-page semantics: proper page announcements, focus reset, a working back button, no router.&lt;/p&gt;

&lt;p&gt;Total change for two multi-day bugs: six declarations and one inline style block.&lt;/p&gt;

&lt;p&gt;You can feel it on the &lt;a href="https://accessible-by-default.dev/" rel="noopener noreferrer"&gt;live site&lt;/a&gt;, and the source is here:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ThomasSweet" rel="noopener noreferrer"&gt;
        ThomasSweet
      &lt;/a&gt; / &lt;a href="https://github.com/ThomasSweet/a11y-foundation" rel="noopener noreferrer"&gt;
        a11y-foundation
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      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.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/ThomasSweet/a11y-foundation/docs/banner.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FThomasSweet%2Fa11y-foundation%2FHEAD%2Fdocs%2Fbanner.svg" alt="Built in, not bolted on — with 'bolted on' struck through" width="1080"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Accessible by default&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/ThomasSweet/a11y-foundation/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/ThomasSweet/a11y-foundation/actions/workflows/ci.yml/badge.svg" alt="CI"&gt;&lt;/a&gt;
&lt;a href="https://accessible-by-default.dev/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/d1e138eac32100e698a6eab425cac007e2f930ca99ff897c55a23bd96aec7f92/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6976652d61636365737369626c652d2d62792d2d64656661756c742e6465762d323536336562" alt="Live site"&gt;&lt;/a&gt;
&lt;a href="https://www.w3.org/TR/WCAG22/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/32a0ace5dd0aa27a0a392f7060354a71ce15bed55bc25c10d3f5622f05c20f43/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f574341475f322e325f41412d7461726765742d313661333461" alt="WCAG"&gt;&lt;/a&gt;
&lt;a href="https://github.com/ThomasSweet/a11y-foundation/./LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/9c07affde96ff6cfbd11de8b722af95eec32e3a544b09bff2855a3191aef309d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d386138613861" alt="License: MIT"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;How much of accessibility does the modern web platform handle &lt;strong&gt;natively&lt;/strong&gt; —
with little to no JavaScript? This site is that question, answered as one
argument in four parts: what &lt;strong&gt;the standard&lt;/strong&gt; (WCAG) asks for, &lt;strong&gt;the craft&lt;/strong&gt;
of meeting it with modern CSS and HTML, what cutting-edge &lt;strong&gt;CSS&lt;/strong&gt; makes
possible next, and &lt;strong&gt;the proof&lt;/strong&gt; that it holds up.&lt;/p&gt;
&lt;p&gt;Underneath it is an accessibility-first styling foundation — SCSS mixins
design tokens, and a cascade-layer architecture — where components adapt to
&lt;strong&gt;user preferences&lt;/strong&gt; (reduced motion, high contrast, forced colors, dark mode
reduced transparency) and &lt;strong&gt;input capabilities&lt;/strong&gt; (hover, touch) by default,
with the cascade doing the work instead of &lt;code&gt;!important&lt;/code&gt;.&lt;/p&gt;
&lt;div class="markdown-alert markdown-alert-note"&gt;
&lt;p class="markdown-alert-title"&gt;Note&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The design vote is settled.&lt;/strong&gt; 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…&lt;/p&gt;
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ThomasSweet/a11y-foundation" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;If you have hit a third cause of flashing in a cross-document transition, I would like to hear about it. Two took me long enough that a catalogue seems worth starting.&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>a11y</category>
    </item>
    <item>
      <title>Every state of the page passed axe. The page was still broken.</title>
      <dc:creator>Thomas Sweet</dc:creator>
      <pubDate>Tue, 28 Jul 2026 17:44:55 +0000</pubDate>
      <link>https://dev.to/thomas_sweet/every-state-of-the-page-passed-axe-the-page-was-still-broken-3g58</link>
      <guid>https://dev.to/thomas_sweet/every-state-of-the-page-passed-axe-the-page-was-still-broken-3g58</guid>
      <description>&lt;p&gt;Open &lt;a href="https://accessible-by-default.dev/showcase.html" rel="noopener noreferrer"&gt;the CSS showcase catalog&lt;/a&gt; on my site. Filter it to "typography" with the topic chips. Now click a sidebar link that points at a layout showcase.&lt;/p&gt;

&lt;p&gt;Nothing happens.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;display: none&lt;/code&gt;. For a keyboard user this is a dead end with no feedback of any kind: you activate a link, and the page silently declines.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why no scanner can catch this
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;But nothing in this page is malformed at any instant. The filter is a set of radio chips plus &lt;code&gt;:has()&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;This is not a gap in axe's ruleset that a future version will close. It is the shape of the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix stayed pure CSS
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@layer&lt;/span&gt; &lt;span class="n"&gt;utilities&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.showcase&lt;/span&gt;&lt;span class="nd"&gt;:target&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.showcase-group&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.showcase&lt;/span&gt;&lt;span class="nd"&gt;:target&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Layer order does the whole job. No &lt;code&gt;!important&lt;/code&gt;, 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.&lt;/p&gt;

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

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

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  What each layer actually caught
&lt;/h2&gt;

&lt;p&gt;The site's &lt;a href="https://accessible-by-default.dev/proof.html" rel="noopener noreferrer"&gt;last chapter&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh4rczlhz98p2loap9838.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh4rczlhz98p2loap9838.png" alt="The five testing layers as a row of connected plates, each annotated with what it caught on this site" width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caught in CI, before anything shipped.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Stylelint enforces the no-unlayered-CSS rule and mixin ordering; &lt;code&gt;vue-tsc&lt;/code&gt; 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 &lt;code&gt;color-scheme&lt;/code&gt; meta, which let native form controls render light inside a dark theme.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caught by others, on their phones&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caught by visitors, after shipping.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  The uncomfortable arithmetic
&lt;/h2&gt;

&lt;p&gt;Line the inventory up and it makes an argument I did not set out to make.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where this actually stands
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://accessible-by-default.dev/" rel="noopener noreferrer"&gt;The site&lt;/a&gt; is also the demo. Every claim above is running code you can poke at, including &lt;a href="https://accessible-by-default.dev/standard.html" rel="noopener noreferrer"&gt;"break this rule" toggles&lt;/a&gt; on the WCAG criteria so you can feel what each one prevents.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ThomasSweet" rel="noopener noreferrer"&gt;
        ThomasSweet
      &lt;/a&gt; / &lt;a href="https://github.com/ThomasSweet/a11y-foundation" rel="noopener noreferrer"&gt;
        a11y-foundation
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      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.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/ThomasSweet/a11y-foundation/docs/banner.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FThomasSweet%2Fa11y-foundation%2FHEAD%2Fdocs%2Fbanner.svg" alt="Built in, not bolted on — with 'bolted on' struck through" width="1080"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Accessible by default&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/ThomasSweet/a11y-foundation/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/ThomasSweet/a11y-foundation/actions/workflows/ci.yml/badge.svg" alt="CI"&gt;&lt;/a&gt;
&lt;a href="https://accessible-by-default.dev/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/d1e138eac32100e698a6eab425cac007e2f930ca99ff897c55a23bd96aec7f92/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6976652d61636365737369626c652d2d62792d2d64656661756c742e6465762d323536336562" alt="Live site"&gt;&lt;/a&gt;
&lt;a href="https://www.w3.org/TR/WCAG22/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/32a0ace5dd0aa27a0a392f7060354a71ce15bed55bc25c10d3f5622f05c20f43/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f574341475f322e325f41412d7461726765742d313661333461" alt="WCAG"&gt;&lt;/a&gt;
&lt;a href="https://github.com/ThomasSweet/a11y-foundation/./LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/9c07affde96ff6cfbd11de8b722af95eec32e3a544b09bff2855a3191aef309d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d386138613861" alt="License: MIT"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;How much of accessibility does the modern web platform handle &lt;strong&gt;natively&lt;/strong&gt; —
with little to no JavaScript? This site is that question, answered as one
argument in four parts: what &lt;strong&gt;the standard&lt;/strong&gt; (WCAG) asks for, &lt;strong&gt;the craft&lt;/strong&gt;
of meeting it with modern CSS and HTML, what cutting-edge &lt;strong&gt;CSS&lt;/strong&gt; makes
possible next, and &lt;strong&gt;the proof&lt;/strong&gt; that it holds up.&lt;/p&gt;
&lt;p&gt;Underneath it is an accessibility-first styling foundation — SCSS mixins
design tokens, and a cascade-layer architecture — where components adapt to
&lt;strong&gt;user preferences&lt;/strong&gt; (reduced motion, high contrast, forced colors, dark mode
reduced transparency) and &lt;strong&gt;input capabilities&lt;/strong&gt; (hover, touch) by default,
with the cascade doing the work instead of &lt;code&gt;!important&lt;/code&gt;.&lt;/p&gt;
&lt;div class="markdown-alert markdown-alert-note"&gt;
&lt;p class="markdown-alert-title"&gt;Note&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The design vote is settled.&lt;/strong&gt; 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…&lt;/p&gt;
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ThomasSweet/a11y-foundation" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>testing</category>
      <category>css</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
