How I got 100 on Lighthouse accessibility
The homepage scored 85 for desktop accessibility. It took one sitting to reach 100, and almost none of the work was about accessibility as a discipline.
What follows is scoped honestly: desktop accessibility, in local Lighthouse runs, verified at 100 on different page types — the homepage, a code-heavy blog post, and the showcase page. It is not a claim about performance or SEO, and local is not the same as field data. But the accessibility number is real, and the six audits underneath it were six genuine bugs.
The useful reframe
A score is a bad thing to optimise and a good thing to read. 85 is not a mystery — it is a list of six named audits, each pointing at a specific element with a specific reason it failed. That makes it a bug list rather than an aspiration.
So I stopped trying to make the number move and just worked down the list. Every item below was already in the code — none of it required knowing what accessibility was.
1. Muted text that never met the contrast floor
WCAG AA asks for 4.5:1 on body text. The third-tier foreground token was well under it, in both themes:
// light
--color-fg-tertiary: #a3a3a3 -> #666666 (2.3:1 -> 5.74:1 on white)
// dark
--color-fg-tertiary-dark: #737373 -> #8f8f8f (4.17:1 -> 4.68:1 on #0a0a0a)The dark one is the instructive failure, because it was close — 4.17:1 against a 4.5:1 floor. It looked fine, and it read as a passing colour. Anything that only just misses is the kind of thing that survives review for years.
2. The syntax highlighter was shipping unreadable colours
This is the one I would not have found by looking at my own stylesheet, because the colours were not mine. Shiki's github-light and github-dark themes both contain token colours that miss AA against their own backgrounds:
// comments in github-dark: #6a737d -> 3.05:1 on #24292e
// `variable` in github-light: #e36209 -> 3.49:1 on #ffffff
{ scope: "dark", from: "#6a737d", to: "#8b949e" },
{ scope: "light", from: "#e36209", to: "#b35200" },The subtlety is that dual-theme output puts both colours on the same span — the light one as color:, the dark one as a --shiki-dark: custom property. You cannot fix this in CSS, because at that point you are inside generated markup you do not control and a stylesheet rule would have to outrank an inline style. So the rendered HTML gets rewritten after highlighting, each half through its own property:
export function fixShikiContrast(html: string): string {
return SHIKI_COLOR_FIXES.reduce((fixed, { scope, from, to }) => {
const property = scope === "dark" ? "--shiki-dark" : "color"
return fixed.replace(
new RegExp(`(?<![-\\w])${property}:${from}`, "gi"),
`${property}:${to.toUpperCase()}`,
)
}, html)
}The lookbehind is doing real work: without it the light pass also rewrites background-color:, because that string ends with the same colour token. The i flag is required because Shiki emits uppercase hex.
And the part I would argue for hardest: this map is enforced by a test that reads the actual theme definitions and fails if an upstream release introduces a new failing colour, or if a replacement here stops passing. A hardcoded contrast patch that nobody checks is just a comment that says the page is accessible.
3. Buttons with no name
Every code block has a copy button rendered as an icon. An icon-only button has no accessible name, so a code-heavy post fails this audit once per block. One attribute fixed all of them:
aria-label="Copy code"The reason this one is worth mentioning is that the failure count scales with the page. A post with many code blocks is not many problems, it is one problem repeated. Fixing it per-instance would have meant editing every copy button again every time a theme changed.
4. Heading levels an editor picked by eye
This was the only one that was a genuine content-model problem rather than a missed attribute. Writers choose a heading style because it looks right in the editor, not because of where it sits in the document. So posts open with an h3 directly under the page's h1, which skips a level.
The tempting fix is to go through the CMS and correct each document. That is a content edit that the next editor undoes, and it fixes the past rather than the class of problem. Better to normalise at render time:
export function normalizeHeadingLevels(levels: readonly number[]): number[] {
let previous = 1
return levels.map((level) => {
const rendered = Math.max(2, Math.min(level, previous + 1))
previous = rendered
return rendered
})
}Four rules, each doing something specific. The page owns the h1, so nothing renders above h2. A heading may sit at most one level below the one before it. Ascending back up is legal, so an h3 after an h2 stays an h3 — only downward skips fail. And levels clamp in place rather than being renumbered: a run of three h3s becomes h2, h3, h3, never h2, h2, h2, which would flatten the document's structure.
5. The animated hero had broken ARIA
The name in the hero rotates through a few roles. GSAP's SplitText wraps each word in spans so they can animate independently, and aria-prohibited-attr flagged it: SplitText had put an ARIA attribute onto an element where it is not allowed.
The fix is to stop describing a decorative animation to assistive tech, and to give the static text a real home: hide the rotating word, add an sr-only sibling carrying the full title. Same information, one accessible path.
6. The document had no language
html-has-lang, failing because <html> had no lang attribute. This is the whole fix:
app: { head: { htmlAttrs: { lang: "en" } } }Screen readers need it to pick a pronunciation, and search engines use it as a signal about content. It costs one line and I had simply never set it.
What the same run turned up
The accessibility audit was the interesting half of that report. The performance half had a finding I did not expect: about 984ms of element render delay on the hero name, which is the LCP element.
The cause was my own entrance animation. I had the hero name fade in on a scroll trigger, which meant the largest paint on the page did not happen until hydration plus a 0.8s tween — and worse, a headline that was already visible on screen would suddenly jump 30px at hydration. The fix was to stop animating the LCP element and keep the effect on the tagline, where it costs nothing measured:
The same run flagged the showcase carousel loading images at the full 1440px source when the cards render at roughly 280–380px. Capping the source at the render width with sizes="380px" cut the srcset candidates from 1440w down to 380w and 760w.
Neither of those was an accessibility bug. Both were found by reading a report I ran for an unrelated reason.
How to verify it properly
A 100 on one page is a 100 on one page. This site has several templates that fail differently — a listing page with no code blocks, a post full of them, and a carousel-heavy showcase — so I re-ran the audit against each and confirmed 100 every time.
The colour fix was the one worth double-checking by hand rather than by score. axe fails a block for either theme, so a dark-mode-only regression would not show up in a light-mode run. Both themes got measured.
None of this was designing for accessibility. It was reading a list of six failing assertions and fixing what they pointed at — which turned out to be the fastest route to 100 I had, and the only one that left the score honest.
