From 27 Accessibility Violations to 1: The Three Fixes That Cleared Our Own Blog
Three days ago this blog had twenty-seven accessibility violations across sixteen pages. As of last night’s re-scan, it has one. We did not rewrite the site. We did not bring in tooling we had not been recommending to everyone else. We changed three files.
On April 15 we published a retrospective about pointing our own scanner at our own blog and finding, among other things, that our Color Contrast Guide itself failed a color-contrast check. At the bottom of that post we wrote that the PR was already open and that we expected it to land before the article itself did. It landed the same day. This post is the receipt.
The shape of the fix
Twenty-six of the twenty-seven violations lived in three shared files. That is the whole story, in one sentence: the failures were architectural, not editorial. Every post on the site rendered the same newsletter component, the same related-resources block, and inherited the same blog-index structure, so a single incorrect pattern in any of those three places multiplied across the catalog. Fixing the pattern once fixed the whole catalog.
Let us walk through the three.
Fix 1 — The opacity trick that was eating our contrast ratio
The worst finding was on the newsletter signup component, because it rendered on almost every post. Fourteen of the original fifteen color-contrast failures came from one line of CSS: opacity: 0.6 applied to the "No spam. Unsubscribe anytime." disclaimer at the bottom of the form.
Here is what we had:
.privacy-note {
font-size: 0.8em !important;
opacity: 0.6 !important;
margin-top: 0.75em !important;
}
And this is why that was a contrast killer. Opacity does not change the color value of the text — it blends whatever foreground color you set with the background color behind it at the ratio you give it. On our inline newsletter card, the box was #f0f4ff and the text was inheriting #444, so at 60% opacity the effective rendered color was somewhere around #88888E sitting on #f0f4ff. Axe-core did the math and reported a contrast ratio of about 3.13:1. WCAG 2.1 AA wants 4.5:1 for normal body text. We missed it by a full step.
The frustrating part is that if you pick the text color in devtools you see #444, which is perfectly fine against #f0f4ff. You have to know to factor in the opacity. We did — we wrote the guide about exactly this — and we still shipped it. It is the single most common color-contrast failure mode we see on client sites that have already been through an audit.
The fix is to drop the opacity trick entirely and set the color explicitly, per variant, because the component renders on both a light card and a dark banner:
.privacy-note {
font-size: 0.8em !important;
margin-top: 0.75em !important;
}
.inline .privacy-note {
color: #555 !important;
}
.banner .privacy-note {
color: #cccccc !important;
}
#555 on #f0f4ff is about 7.2:1. #cccccc on the banner’s #1a1a2e gradient is about 10:1. Both comfortably clear AA (4.5:1) and clear AAA (7:1) for the inline case. More importantly, whatever we render on top of those variants, the number a human or a scanner reads when they inspect the element is the actual contrast — no hidden blending step.
If you have been using opacity to “soften” disclaimers, captions, timestamps, or helper text on your site, this is the fix for your site too. Scan for it.
Fix 2 — When an aside is not a good aside
The second-biggest rule violation was landmark-complementary-is-top-level, firing on eleven pages. In every case the node was the same: our RelatedResources component, which was wrapped in <aside class="related-resources"> and rendered inside <main>.
Before:
<aside class="related-resources">
<h2>Related Resources</h2>
<ul>
...
</ul>
</aside>
The <aside> element carries an implicit ARIA role of complementary. Complementary is one of the top-level landmark roles, and axe-core enforces — correctly, per the ARIA authoring practices — that top-level landmarks should not be nested inside other landmarks. Since our layout wraps every post body in <main>, the aside was always sitting inside a landmark, and the rule always fired.
There are two clean ways to resolve this. The first is to lift the aside out of <main>, so it renders as a sibling of the article instead of a child. That is the right call if the content is genuinely supplementary to the whole page — a site-wide sidebar, for example, or a promo block that is not tied to the current article. The second is to change the element so it no longer claims the complementary role. That is the right call if the content is related to the page content and belongs inside the reading flow.
Our related-resources block is specifically a list of links that relate to the post you just finished reading. It is not a generic sidebar. We wanted it inside the article flow. So we changed the element:
<section class="related-resources" aria-labelledby="related-resources-heading">
<h2 id="related-resources-heading">Related Resources</h2>
<ul>
...
</ul>
</section>
A <section> with an accessible name (via aria-labelledby) becomes a named region in the accessibility tree without claiming complementary. Screen-reader users get a navigable region with the heading as its label. Axe-core is happy. The visual output is identical. Our ARIA attributes guide has more on when to use aria-labelledby vs. aria-label if you are picking one.
One rule of thumb: if you are reaching for <aside> because you wanted a visual box, you probably want <section> instead. <aside> should mean “this content would still make sense if you removed it from the flow.” Almost every “related posts” block fails that test.
Fix 3 — The blog index had no h1 and the post titles were h4s
The last two rule failures were both on /blog/ and both structural. The blog index had no <h1> at all (page-has-heading-one), and the list of post titles was rendered as a stack of <h4> elements with no intermediate headings above them (heading-order, which complains when heading levels jump by more than one).
Here is the diff, simplified:
// BEFORE (src/pages/blog/index.astro)
<main>
<section>
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.id}/`}>
<h4 class="title">{post.data.title}</h4>
...
</a>
</li>
))}
</ul>
</section>
</main>
// AFTER
<main>
<h1>Web Accessibility Guides, Audits, and Compliance</h1>
<section>
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.id}/`}>
<h2 class="title">{post.data.title}</h2>
...
</a>
</li>
))}
</ul>
</section>
</main>
Two changes, one file. The <h1> gives the page a named top-level heading — something a screen-reader user can jump to with the 1 key in most reading modes, and something the SEO crawler can use to understand what the page is actually about. It is a common myth that the <h1> has to match <title> exactly or that having an <h1> duplicates the <title>. It does not, and it should not. The page <title> lives in the tab; the <h1> lives in the document.
Demoting the post titles from <h4> to <h2> fixes the other rule — there is now no level-jump between the page’s <h1> and the list items. And it happens to be worth doing for search as well. Google’s own documentation has been explicit for years that they use heading structure as one of the signals for section relevance. A blog index whose post titles are all <h4>s tells a crawler that the post titles are fourth-order subsections of something — but nothing at levels 1 through 3 exists, so there is nothing for them to be subsections of. Fixing the a11y failure also fixed the IA failure.
The numbers after
We re-ran the same scan the morning after the PR landed. Same harness, same sixteen pages, same rule set.
- Before: 27 violations, 28 nodes, 14 pages with 1+ issues, 1 clean page
- After: 1 violation, 1 node, 15 clean pages, 1 page with a single remaining issue
- Delta: -96.3%
Fifteen out of sixteen pages went from failing to fully clean. Every single landmark-complementary-is-top-level hit cleared. Both heading-order and page-has-heading-one cleared. The whole .privacy-note contrast cluster cleared. One color-contrast violation is left, on the accessible email marketing guide, and it is not in our code at all — it is a syntax-highlighted comment token in a code block, rendered by the upstream syntax-highlighting theme we use for markdown. The comment color against the code-block background is about 3.04:1.
That one is worth a paragraph on its own because it is the kind of thing that tends to get misreported. We could either file an upstream change against the theme (the right long-term move, because other sites benefit), or we could override the token color in our own stylesheet (the right short-term move, because the upstream PR cycle is measured in weeks and our readers are on the site today). We are doing both: override locally this week, PR upstream when we have time to write the reproduction properly. We will note which pull request and which override in the next re-scan.
What the timeline actually looked like
The sequence was tight, on purpose:
- April 13, morning. Ran axe-core against
blog.a11yfix.devfor the first time ever. 27 violations. - April 14, evening. Three fixes authored, local scan down to 1 remaining violation.
- April 15, morning. Retrospective article published, PR still open.
- April 15, late afternoon. PR merged, staging re-scan confirmed -26.
- April 16, morning. Production re-scan confirmed -26 on the live site.
- April 18. This post.
Scan → admit → fix → measure in under twenty-four hours of working time, across the weekend. Not because we are fast, but because the fixes were small once we actually ran the scanner. The slow part — the years-long part, honestly — was not scanning in the first place. This is what the phrase “fix it before you teach it” ends up meaning in practice: you do not get to write a guide about a rule you are currently breaking, and the only way to find out whether you are breaking it is to run the tool you keep recommending to other people.
One is not zero. There is still one violation on the site, and until that last one is gone we are not clean in the sense that matters to us. But the loop — scanner runs, report reads, PR lands, re-scan passes — closed faster than we expected. It closed faster than the article we wrote about opening it. We will take that.
If you want to run the same check on your own site tomorrow morning, the toolchain is all open source: axe-core plus @axe-core/puppeteer, pointed at a list of URLs. Our harness is a thin wrapper around those two. No magic. The first scan is the hard part. The fixes almost always turn out to be in three files.
Get our free accessibility toolkit
We're building a simple accessibility checker for non-developers. Join the waitlist for early access and a free EAA compliance checklist.
No spam. Unsubscribe anytime.