Accessible Code Blocks in Documentation Sites
Documentation sites are made of code blocks. The reference site from Accessibility for Static Sites has about 2,600 of them across 1,140 pages, and every one was generated by the same highlighter configuration. That is good news: three issues that affected all 2,600 blocks were each fixed once. The issues were keyboard access to blocks that scroll, contrast of the syntax colours, and names for the block and its copy button.
This guide fixes all three in Astro with Shiki (via Starlight's Expressive Code and plain Astro Markdown), and shows the equivalent for Prism in Eleventy and Chroma in Hugo.
Prerequisites
- The site's highlighter configuration: Shiki in Astro,
@11ty/eleventy-plugin-syntaxhighlight(Prism) in Eleventy, ormarkup.highlight(Chroma) in Hugo. - axe-core to verify, and a screen reader for the naming checks.
What Was Wrong
An axe scan of 60 pages plus a manual pass found:
| Issue | axe rule | Elements |
|---|---|---|
| Overflowing blocks not keyboard-scrollable | scrollable-region-focusable | 612 of 1,384 blocks scanned |
| Comment and punctuation tokens below 4.5:1 | color-contrast | 1,090 spans |
| Copy button with only an icon | button-name | 1,384 |
| Line numbers read aloud before every line | — (manual) | Blocks with numbers |
Step 1: Make Overflowing Blocks Focusable
A block scrolls when its longest line is wider than the content column. On the reference site the column holds about 80 characters of monospace at desktop widths and about 38 on a 390 px phone, so on mobile almost every block scrolls. The cleanest fix is a small script that checks real overflow after layout and adds tabindex="0", a role and a name only where needed:
for (const pre of document.querySelectorAll('pre')) {
if (pre.scrollWidth > pre.clientWidth) {
pre.tabIndex = 0;
pre.setAttribute('role', 'region');
pre.setAttribute('aria-label', `Code: ${pre.dataset.language || 'text'}`);
}
}
Run it on load and on a debounced resize. It is 0.3 KB. For a site that wants no script at all, a rehype plugin can add the attributes at build time to any block with a line over a threshold; the reference site uses 38 characters so that mobile is covered, accepting a few unnecessary tab stops on desktop. Starlight's Expressive Code adds tabindex="0" to every block already, which is why the docs section passed and only the marketing pages' plain Markdown failed.
Equivalent hooks in the other generators:
- Eleventy (Prism): an Eleventy transform over HTML output, or the
markdown-itfence renderer override, to add the attributes to<pre>. - Hugo (Chroma): a code block render hook in
layouts/_default/_markup/render-codeblock.htmlthat wraps the highlighted output in<div class="code" tabindex="0" role="region" aria-label="Code: {{ .Type }}">.
Step 2: Fix Token Contrast
The site used Shiki's github-light and github-dark themes. Measuring every token colour against its theme background:
| Token | Light theme | Dark theme |
|---|---|---|
| Keyword | 6.1:1 | 6.8:1 |
| String | 5.6:1 | 9.3:1 |
| Comment | 4.6:1 | 4.1:1 |
| Punctuation | 4.9:1 | 4.4:1 |
| Line highlight background | — | changes all ratios on that line |
Two dark-theme tokens failed, and highlighted lines (with a lighter background) pushed several more under. Shiki's colorReplacements option overrides individual colours without forking the theme:
// astro.config.mjs
markdown: {
shikiConfig: {
themes: { light: 'github-light', dark: 'github-dark' },
colorReplacements: { 'github-dark': { '#6a737d': '#8b949e', '#e1e4e8': '#f0f3f6' } }
}
}
The same approach works for Prism by overriding .token.comment in CSS, and for Chroma by generating the style with hugo gen chromastyles --style=github-dark and editing the two colours.
Step 3: Name the Copy Button
The copy button rendered an SVG clipboard icon in a <button> with no text. Screen readers announced "button". The fix is a visually hidden label and a status message when the copy succeeds:
<button class="copy" type="button">
<span class="icon icon-copy" aria-hidden="true"></span>
<span class="sr-only">Copy code</span>
</button>
<span class="sr-only" role="status" aria-live="polite"></span>
On click, the script writes "Copied" into the status element for two seconds. Screen reader users hear the confirmation that sighted users get from the icon changing to a tick. The sr-only class is the usual clip pattern; the skip link guide uses the same technique.
Step 4: Keep Line Numbers Out of the Reading
Blocks with line numbers rendered them as text inside each line, so NVDA read "one const x equals one two const y equals two". Rendering them with a CSS counter on ::before keeps them visual only:
pre.numbered code { counter-reset: line; }
pre.numbered .line::before {
counter-increment: line; content: counter(line);
display: inline-block; width: 2.5ch; margin-right: 1.5ch;
text-align: right; color: var(--text-muted); user-select: none;
}
Generated content in ::before is sometimes read by screen readers; in testing, NVDA and VoiceOver both skipped counter-only content inside <code>, but to be certain the reference site also wraps the counter in an aria-hidden span in builds where the highlighter supports it. user-select: none keeps the numbers out of copied text, which readers had complained about separately.
Tabs, Diffs and Terminal Output
Three variations of the code block need extra care. Tabbed blocks — npm, pnpm and yarn versions of the same command — must follow the WAI-ARIA tabs pattern: a role="tablist" of buttons with aria-selected, arrow keys to move between them, and only the selected panel in the tab order. Starlight's <Tabs> component and VitePress's code groups both implement it; a hand-rolled version on the marketing pages did not, and was replaced. A useful extra is to remember the reader's choice of package manager in localStorage and apply it on every page, so a pnpm user is not switching tabs on every install command.
Diff blocks show added and removed lines with green and red backgrounds. Colour alone fails WCAG 1.4.1, which forbids using colour as the only way to convey information, so the + and - markers must remain visible — some highlighters hide them for a cleaner look. Screen readers read the markers, which is what makes the diff understandable without sight. Check the contrast of token colours on both diff backgrounds as well; they are the lowest-contrast combinations on most sites.
Terminal output is often long and wide. Wrapping it (white-space: pre-wrap) is usually better than scrolling, because line breaks in log output rarely carry meaning, and a wrapped block needs no tab stop. The reference site wraps blocks with the text, log and console languages and scrolls everything else.
Measured Impact
| Measure | Before | After | Tool |
|---|---|---|---|
scrollable-region-focusable | 612 elements | 0 | axe-core 4.10 |
color-contrast in code | 1,090 spans | 0 | axe-core 4.10, both themes |
button-name | 1,384 | 0 | axe-core 4.10 |
| Line numbers read aloud | Yes | No | NVDA speech viewer |
| JavaScript added | — | 0.6 KB | Build output |
Pitfalls & Rollback
- Focusable blocks without names. A
tabindexregion needsaria-label. - Checking contrast only on the default background. Highlighted lines and diff backgrounds change every ratio.
- Icon-only buttons. Every button needs text, visible or not.
- Rollback: each change is in the highlighter config, one CSS block and a 0.6 KB script; reverting any one leaves the others working.
Conclusion
Code blocks fail accessibility in four repeatable ways — scroll without focus, low-contrast tokens, unnamed copy buttons and line numbers read aloud — and because a static site generates every block from one configuration, each fix applies everywhere. On the reference site, four changes cleared 3,086 flagged elements across 2,600 blocks.
FAQ
Should code blocks have tabindex 0?
Only the ones that scroll. A block that overflows horizontally must be focusable so keyboard users can scroll it with the arrow keys. Adding tabindex to every block, including short ones that never scroll, adds pointless tab stops. Decide at build time from line length, or at runtime by comparing scrollWidth with clientWidth.
How do screen readers read syntax-highlighted code?
They read the text and ignore the colour spans, so highlighting itself does not get in the way. Problems come from line numbers rendered as text, which are read before every line, from copy buttons without names, and from blocks with no indication of language. Render line numbers with CSS counters or aria-hidden so they are not read.
Is it acceptable to wrap long lines instead of scrolling?
Wrapping avoids the keyboard problem entirely and suits prose-like content such as shell output. For code where indentation and line breaks carry meaning, wrapping can mislead, so most documentation sites scroll by default and offer a wrap toggle. Either way, the block must work without a mouse.
Which highlighter themes pass contrast requirements?
Few pass out of the box for every token. GitHub Light and GitHub Dark in Shiki pass for most tokens but have comment colours near 4.5 to 1. Many popular dark themes have comments around 3 to 1. Measure the theme's token colours against its background and override the failing ones rather than assuming.
Related
- Parent: Accessibility for Static Sites — code blocks among the build-output issues.
- Fixing Low Color Contrast Flagged by Lighthouse — the token approach for the rest of the palette.
- Keyboard Navigation and Focus Order on Static Sites — why scrollable regions need focus.
- Markdown Pipelines: remark vs markdown-it — where the build-time plugin hooks in.
- Accessible SVG Diagrams on Static Sites — the other generated content type.
Filed underHow-tos