Skip to content

Fix accessibility issue with scrollable code blocks#343

Merged
hippotastic merged 2 commits intoexpressive-code:mainfrom
ruslanpashkov:add-aria-region-role
Jul 5, 2025
Merged

Fix accessibility issue with scrollable code blocks#343
hippotastic merged 2 commits intoexpressive-code:mainfrom
ruslanpashkov:add-aria-region-role

Conversation

@ruslanpashkov
Copy link
Copy Markdown
Contributor

We had a WCAG compliance issue where scrollable code blocks weren't properly accessible to screen readers and other assistive technologies.

What was wrong?
When code blocks become scrollable, we add tabindex="0" so people can tab to them and scroll with their keyboard. But we weren't telling screen readers what kind of interactive element this is - it was just "some interactive thing" with no clear purpose. This violates WCAG 4.1.2 Name, Role, Value.

The fix
Now when we add tabindex="0" to make a code block keyboard-accessible, we also add role="region" to tell assistive technologies "this is a scrollable content area you can navigate to."

Why role="region"?
It's the standard way to mark scrollable content sections. Screen readers understand it well and it doesn't mess with the meaning of our code blocks - it just makes them properly accessible.

What changes?

  • Scrollable code blocks get both tabindex="0" and role="region"
  • When they're not scrollable anymore, both attributes get removed
  • Everything else works exactly the same

This is a small change that makes our code blocks work better for everyone using assistive technologies, without breaking anything or changing how they look or behave for other users.

@netlify
Copy link
Copy Markdown

netlify bot commented Jun 30, 2025

Deploy Preview for expressive-code ready!

Name Link
🔨 Latest commit 3184c08
🔍 Latest deploy log https://app.netlify.com/projects/expressive-code/deploys/686963399fef0f0008a22d71
😎 Deploy Preview https://deploy-preview-343--expressive-code.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link
Copy Markdown
Collaborator

@hippotastic hippotastic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thank you very much for this fix and the detailed explanation.

@hippotastic hippotastic merged commit eb82591 into expressive-code:main Jul 5, 2025
5 checks passed
@delucis
Copy link
Copy Markdown
Collaborator

delucis commented Jul 7, 2025

Hey @ruslanpashkov! Could you provide some links documenting your arguments for the change made in this PR? I’m a bit doubtful about making code blocks landmark regions just because they’re scrollable. A landmark region is supposed to be something that makes sense to navigate to directly, without other page context, and a code block is usually presented in context. Adding and removing landmark regions dynamically could be particularly confusing for users.

Additionally, in my testing with VoiceOver, role="region" has no impact, partly because landmark regions need to be labelled to be useful.

For example, if I navigate to https://expressive-code.com/upgrading/ and make the viewport narrow enough to make code blocks scrollable, none of these code blocks show up in VoiceOver’s landmark navigation pane:

VoiceOver landmark navigation pane, 6 items: banner, Main navigation, complementary, On this page Overview navigation, main, Caution complementary

To make them appear, they need to be accessibly labelled using aria-labelledby or aria-label. For example, on that same page, if I add a few aria-label attributes to the regions, then they show up in VoiceOver’s landmark navigation pane:

VoiceOver landmark navigation pane, 8 items, the same 6 as in the image above, followed by: package.json region, install cli region

For Expressive Code code blocks with a title, it would be possible to generate an id on the title element and link it to the region using aria-labelledby, but I’m not sure what you’d do for unlabelled code blocks.

Given that this PR introduces errors in audit tools like axe-core and as far as I’m aware doesn’t actually improve things, it might make sense to revert?

@ruslanpashkov
Copy link
Copy Markdown
Contributor Author

Hey @delucis!

Thank you for your feedback - you're absolutely right about the problem with unlabelled landmark elements. But the issue is that we need a role attribute not just because this is a scrollable block, but primarily because it's an interactive element.

Let me try to clarify.

You correctly pointed out the landmark problem, and even adding proper aria labels here would create more noise for screen readers than actual benefit. Your VoiceOver research is also spot on — while some screen readers might read this block as a region, it doesn't add any real value for them. Plus, axe-core clearly flags this as an issue, and I discovered the same problem independently.

During my audit, I found a 4.1.2 Name, Role, Value compliance issue on these elements because they don't have a semantic tag appropriate for interactive elements (which our code block is, since it's a scrollable element). I decided to check this with Arc Toolkit and it turned out to be correct - it gave me the same message:

image

So it turns out that landmark roles are inappropriate in this situation, but we still need to explicitly define the element's role.

I researched what value would be more appropriate in this situation and found that Eric Bailey recommends using role="group" in similar cases in his article about tabindex attribute for The A11Y Project:

A way to specify what the content contains semantically. This can be provided by:

  1. An applicable sectioning element, or
  2. A Landmark Role if the content is important enough that people need a quick and easy way to return to it.
  3. A group role can also be used if there isn't a need for quick access.
<div
  aria-labelledby="terms-of-service"
  role="group"
  tabindex="0"
  style="overflow: auto; height: 15rem;">
  <h2 id="terms-of-service">Terms of Service</h2></div>

This satisfies WCAG criterion 4.1.2: Name, Role, Value. It allows people to scroll using a keyboard, as well as providing an indication on why they should scroll, and what content they can expect to find within.

Looks like it could be a good solution, but I also found this post about role="code", which is not a landmark element and is more semantic in our case.

I think changing role attribute to group or code perfectly fits our situation and solves both the real accessibility issue for users and W3C compliance requirements. I've also tested both approaches with VoiceOver and NVDA, as well as automation tools (WAVE, Arc Toolkit, and axe-core), and everything works fine with no errors or accessibility issues.

I think we should go with role="code" since it's more semantically accurate and some screen readers may read full punctuation in the code blocks, which is actually helpful for developers. But I'm open to role="group" if you think that's better - what's your take?

@delucis
Copy link
Copy Markdown
Collaborator

delucis commented Jul 7, 2025

Thanks for the additional context and links. The <code> element in code blocks already brings role="code" implicitly, so I don’t think there’s any need to add that.

I guess the role="group" approach improves on the region role, but we’d still have the issue of no accessible label?

In your testing what differences did you see between no role and role="group"?

@delucis
Copy link
Copy Markdown
Collaborator

delucis commented Jul 7, 2025

Shared this with some of the Astro maintainers and @OliverSpeir dug up a good example over at Microsoft Learn. For example: https://learn.microsoft.com/en-us/training/modules/dotnet-dependencies/3-exercise-dependency

They use role="group" and combine it with a generic aria-label="Horizontally scrollable code" something like this:

<pre role="group" aria-label="Horizontally scrollable code" tabindex="0">
	<code>
		...
	</code>
</pre>

I chatted with one of the Microsoft Learn team on this topic last year where we concluded this was a stopgap while waiting for all browsers to make scrollable regions focusable by default. Chrome joined Firefox earlier this year, so hopefully soon all tabindex and role hacks will become redundant.

@ruslanpashkov
Copy link
Copy Markdown
Contributor Author

Currently, labeling scrollable elements is considered a best practice, but it is not mandatory for WCAG compliance.

If we want a great user experience, we need ARIA labeling. However, it lacks translation support, though it is probably still better than nothing, especially in a code block context.

I think the best solution here is to add a generic aria-label like the one you shared from Microsoft Learn, along with role="group", since it would be easier to remove once there's wider browser support or a guidelines review.

It will resolve the accessibility issue in tools like VoiceOver and NVDA (although JAWS reads this block even without the defined ‎aria-label), and address the compliance issue.

@delucis
Copy link
Copy Markdown
Collaborator

delucis commented Jul 8, 2025

Yeah, sounds like a decent plan. 👍

Expressive Code does at least include APIs for localization, so users can adjust the label for multilingual use even if aria-label doesn’t work well for browser auto translation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants