React

Imagine a language toggle on a website. Then user clicks it, the content and UI swaps from English to French, it looks done but what’s expected for accessibility? There are 3 aspects that need checking in this context.

Language toggle UI component with English and French as options

WCAG 3.1.1 (language of page) <html lang="">has to update

Screen readers use the lang attribute to choose a pronunciation engine. If it’s stuck on en while the visible text is now French, every word gets read with English phonetics. Its completely mispronounced, it’s a bit like Siri reading French in a English accent, it’s going to get all sorts of things wrong.

WCAG 2.4.2 (page titled) <title> has to update

As the language changes so does the title “Welcome to our website” changes to “Bienvenue sur notre site web.” Screen reader title announcements, and browser history all rely on this.

WCAG 2.4.3 - focus has to be considered

The user clicks the language toggle, the content changes however the user’s focus needs to be considered.

WCAG 4.1.3 - status messages

Requires that important changes, be announced to screen readers even if the user’s focus never moves there.

React has an architectural gap

React doesn’t automatically handle any of these, it’s down to the Developer to be aware and fill in these gaps.

Does SSR (server-side rendering) fix these issues within React?

Not really. SSR (Server-Side Rendering) simply means computing the HTML on the server before sending it to the client. SSR is a prerequisite for progressive enhancement in JavaScript frameworks.

Server-side rendering lets you compute the correct lang and <title> before the HTML leaves the server. Search, AI crawlers and assistive tech that read the document before hydration get the right values immediately.

But SSR has nothing to say about what happens after hydration, when a user clicks the toggle without a full page reload. That’s a client-side state change happening entirely within the React tree, and SSR’s job is already finished by then. SSR fixes the document the server ships on first load. It doens’t handle the interaction that happens after, React doesn’t ship an aria-live title-watcher to address that gap automatically.

Quick aside, this client-side interaction gives a smooth interaction to the user, they don’t get a page reload flash when interacting with the page, but this is less relevant with @view-transition now shipping in many browsers, a great demo can be found here.

Does React Router fix things?

Ryan Florence, who built Reach Router wrote about why automatic focus management on route change got dropped, he laid out the actual blocker, “focus management and scroll restoration are, in most cases, mutually exclusive, because moving focus to a new element also scrolls it into view, which fights with React Router’s scroll restoration on back/forward navigation.”

“this is a big problem that I don’t know how to solve and why I’ve opened this discussion. Without a resolution here this effort is blocked”

The official migration guide from Reach Router to React Router v6 stated a goal of the rewrite “stop doing not-good-enough focus management by default.” The people who built the router, hit this wall and documented hitting it. React Router’s own current accessibility docs now frame it as your problem to solve.

React Router doesn’t make any assumptions about your UI as the route changes. There are some important features you’ll want to consider as a result, including: Focus management and Live-region announcements.

Chris Ferdinandi ran into the same issue last year, “I recently worked on a project with React Router, which I thought handled this automatically, but for some reason was not in my app”.

Ok this is React, does Next.js fix this?

The App Router’s generateMetadata solves <title> and announcement issue with caveats better than plain React but there is an open bug that mentions the focus issue in particular saying “Today, nextjs does nothing to help with this.” It seems like some version of this has been around since 2019.

So what actually fixes these issues in React?

Ok, so now we know the problems and a bit of the history of what-is-and-isn’t-possible what are the steps needed for the fixes?

1. Change the lang attribute

This is the only one that’s a single, simple DOM write addressing WCAG 3.1.1

useEffect(() => {
  document.documentElement.lang = locale; // 'fr'
}, [locale]);

A quick note, if your language toggle lives inside a design system (DS), it generally can’t reach past its own subtree to mutate document.documentElement and doing so anyway is bad practice for a shared component. The realistic pattern is for the DS component to tell the app something changed via a callback, the app is responsible for updating the document.

2. Announce the change with custom text, not the title

One fix is creating new element with an aria-live region so this can announce the content changes “Language changed to French”, as a spoken message addressing WCAG 4.1.3. There might dedicated React plugins that address this.

const [announcement, setAnnouncement] = useState('');
 
useEffect(() => {
  document.title = translatedTitle;                          // updates the real title, for tabs/history
  setAnnouncement(`Language changed to ${languageName}`);    // separate, custom text for AT
}, [locale, translatedTitle, languageName]);
 
<div aria-live="polite" className="visually-hidden">{announcement}</div>

This doesn’t touch document.documentElement.lang, so it’s possible to ship an aria-live="polite" region that proudly announces a language switch, in an English-voiced, mispronouncing the French it’s reading, because the structural lang attribute it depends on is still wrong.

Quick digress, there is a hack to announce a document’s title changes with aria-live with display: block on <head> and <title>, but it’s fragile and inconsistently supported across screen readers not a real fix.

3. Shift focus, but where? It depends on context

In this context switching the language acts as a full content change, managing the focus so assistive tech users aren’t left stranded on the toggle. Shifting focus to the body is one choice but this is debatable.

In 2019, Marcy Sutton did some disabled user testing with Fable, across five participants using screen readers, screen magnification, voice navigation, keyboard-only, and switch access, found three workable approaches:

  • Focus the heading <h1> this tested as the fastest, clearest experience, it announces the new content immediately and drops the user straight into it.
  • Focus the content wrapper <main>, also moved screen reader users past top-level navigation successfully, though testers found it noticeably more subtle than a heading.
  • Focus a visually hidden skip-link. Headings and wrappers worked well for screen readers but broke down elsewhere. Screen magnification users hit some problems, a wrapper or heading spanning a wide layout caused the browser to scroll to the middle of it when zoomed in, cutting off text at both edges. For this group, doing nothing sometimes beat the “best practice” pattern. Separately, keyboard-only users couldn’t reach the <main> landmark at all, since landmark regions aren’t keyboard-focusable by default. A small, focusable skip-link sidesteps both problems.

For a complex UI, the skip-link appears to be the safer default specifically because it was the only pattern that held up across every disability group tested. For a straightforward use-case or site, focusing the <h1> or <main> wrapper gives a good experience.

import { useEffect, useRef } from 'react'

function Page({ locale }) {
  const headingRef = useRef(null)

  useEffect(() => {
    // tabIndex={-1} lets us focus it programmatically
    // without adding it to the natural tab order
    headingRef.current?.focus()
  }, [locale])

  return (
    <h1 ref={headingRef} tabIndex={-1}>
      {translatedTitle}
    </h1>
  )
}

Anything else?

For the title specifically, React 19 added native support for hoisting <title> tags rendered anywhere in your tree up into <head>. Worth knowing if you’re on an older React version but native hoisting only covers <title>, <meta>, <link>, <style>, and <script> but it explicitly does not cover lang, which is htmlAttributes, and it does nothing for the announcement live region either.

React is one of many options, SvelteKit doesn’t have these issues

These gaps don’t apply to all JS libraries / frameworks. For example, SvelteKit ships fixes for all three problems as framework defaults.

SvelteKit’s accessibility docs describe a built-in fix, the framework injects a live region onto the page that reads out the new page name after every navigation, determining what to say by inspecting the <title> element itself.

You still have to set a unique title per page, using <svelte:head> which can appear in any component and writes directly into document.head but the live-region announcement on top of it is the framework’s job, not yours.

However just like React, if you are doing a client side route change without a full page reload, you still need to update the lang attribute client-side using <svelte:document> and focus is returned the body but this can be managed using autofocus. React has none of this.

The takeaway

These are architectural gaps with React, its down the the developer to be aware, resolve and manage these issues with ensure accessibility standards are met.