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, or markup.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:

Issueaxe ruleElements
Overflowing blocks not keyboard-scrollablescrollable-region-focusable612 of 1,384 blocks scanned
Comment and punctuation tokens below 4.5:1color-contrast1,090 spans
Copy button with only an iconbutton-name1,384
Line numbers read aloud before every line— (manual)Blocks with numbers
Number of elements affected by each code block issue Horizontal bars. Unnamed copy buttons affected 1,384 elements. Low-contrast syntax tokens 1,090 spans. Non-focusable scrolling blocks 612. Line numbers read aloud affected 240 blocks. Code block issues on the 60-page sample Unnamed copy button 1384 Low-contrast tokens 1090 Scroll not focusable 612 Line numbers read 240
All four came from the highlighter configuration, so each was fixed once for the whole site.

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-it fence renderer override, to add the attributes to <pre>.
  • Hugo (Chroma): a code block render hook in layouts/_default/_markup/render-codeblock.html that 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:

TokenLight themeDark theme
Keyword6.1:16.8:1
String5.6:19.3:1
Comment4.6:14.1:1
Punctuation4.9:14.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.

Contrast ratio of dark theme syntax tokens before and after colour replacements Paired bars. Comments went from 4.1 to 5.6 to 1. Punctuation from 4.4 to 5.9. Comments on a highlighted line from 3.5 to 4.8. Keywords were unchanged at 6.8. Dark theme token contrast before and after Before After Comment 4.1:1 5.6:1 Punctuation 4.4:1 5.9:1 Comment on highlighted line 3.5:1 4.8:1 Keyword 6.8:1 6.8:1
Two colour replacements fixed all dark-theme failures, including those on highlighted lines.

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.

Screen reader output for a numbered code block before and after Two panels. Before, NVDA read button, then one const x equals one, two const y equals two. After, NVDA read region Code javascript, const x equals one, const y equals two, and Copy code button. What a screen reader hears from one numbered block Before "button" "one, const x equals one" "two, const y equals two" no name, numbers read After "Code: javascript, region" "const x equals one" "Copy code, button" named, numbers silent
Recorded with NVDA 2025.3 speech viewer on Firefox.

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

MeasureBeforeAfterTool
scrollable-region-focusable612 elements0axe-core 4.10
color-contrast in code1,090 spans0axe-core 4.10, both themes
button-name1,3840axe-core 4.10
Line numbers read aloudYesNoNVDA speech viewer
JavaScript added—0.6 KBBuild output

Pitfalls & Rollback

  • Focusable blocks without names. A tabindex region needs aria-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.

Filed underHow-tos