docs/oss/building-features/accessibility.md
This page covers accessibility problems caused by the Rails/React boundary in React on Rails and React on Rails Pro apps. It is not a general accessibility handbook. For WCAG rules, ARIA patterns, keyboard behavior, and screen reader basics, use General Web Accessibility References.
Target: WCAG 2.2 Level AA. See the W3C quick reference for the criteria.
These React on Rails terms appear throughout this page:
react_component is the Rails view helper that writes the mount point and passes props to your component.prerender: true tells React on Rails to render the component HTML on the server; prerender: false leaves only the container until JavaScript loads.stream_react_component is the React on Rails Pro helper for streaming server-rendered HTML as it becomes ready.railsContext is request-level data that React on Rails passes to render-functions, such as locale.Framework-specific
react_component accessibility contractGeneral references
When Rails renders the page shell and React renders islands inside it, screen readers get one combined page. If both sides add the same page structure, users hear duplicate landmarks, duplicate headings, or missing context.
Fix this by deciding which side owns each page-level item.
| Concern | Usually owned by |
|---|---|
<html lang>, dir, <title>, meta | Rails layout (ERB) |
Landmarks: <header>, <nav>, <main>, <footer> | Rails layout, usually |
| Skip links | Rails layout |
The single <h1> | Decide explicitly: layout or island |
| Flash messages container + live region | Rails layout, with React writing into it when needed |
| Interactive widgets, focus management, live updates | React islands |
Use react_component when Rails should place a React island on the page:
<%= react_component(
"ProductSummary",
props: { product_id: @product.id },
prerender: true
) %>
Guidance:
<main>, the island should not render another <main>.<h1>. If the island renders it, the layout should not.prerender: true for content that must exist in the first HTML response.prerender: false only for client-only widgets where an empty first response is acceptable.react_component accessibility contractWhen you call react_component, React on Rails writes a container element. React then mounts your component inside that container as its children. The container stays in the DOM and in the accessibility tree.
If you omit an id, React on Rails auto-assigns one to the container.
<%= react_component("AccountMenu", props: { signed_in: true }) %>
tag. The default container is a <div>. That is usually fine because a plain <div> has no landmark or widget meaning. If the container itself needs attributes, pass them through html_options. To change the container element, put tag inside html_options. (The tag option applies to react_component only — the react_component_hash helper always renders a <div> container.)<%= react_component(
"InlineBadge",
props: { text: "New" },
id: "account-badge",
html_options: { tag: "span", class: "badge" }
) %>
Before: this creates two navigation landmarks.
<%= react_component(
"HeaderNav",
props: {},
html_options: { role: "navigation", "aria-label": "Main" }
) %>
export default function HeaderNav() {
return <nav aria-label="Main">...</nav>;
}
After: keep the container neutral and let the component own the landmark.
<%= react_component("HeaderNav", props: {}) %>
export default function HeaderNav() {
return <nav aria-label="Main">...</nav>;
}
id with the top-level id: option, not inside html_options. React on Rails overwrites html_options[:id] with the value from the top-level id: option (or an auto-generated id), so an id placed inside html_options is ignored. Put class, style, role, and aria-* in html_options; put id at the top level. (role="status" already implies aria-live="polite", so it is not repeated here.)<%= react_component(
"SaveStatus",
props: { state: "saving" },
id: "save-status",
html_options: { role: "status" }
) %>
id comes from the top-level id: option (or is auto-generated). Your component still needs its own stable IDs for labels, descriptions, and ARIA relationships inside the island. See section 4.The markup inside the component follows normal web accessibility rules: native elements first, labels for inputs, names for icon-only buttons, visible focus, and correct keyboard behavior. For those rules, use WAI-ARIA APG and MDN Accessibility.
When you use prerender: false, the first HTML response contains the container but not the component content. Screen readers and no-JS users get an empty island until the JavaScript bundle loads.
Fix this by using prerender: true for content-bearing islands.
<%= react_component("ArticleBody", props: { article_id: @article.id }, prerender: true) %>
<%= react_component("ColorSchemeToggle", props: {}, prerender: false) %>
React hydration then attaches event handlers and client behavior to the server-rendered HTML. The accessibility risk is that users can reach HTML before hydration is done.
Guidance:
Date.now(), Math.random(), browser-only checks, or client-only locale detection in render output.railsContext in a render-function, so server and client render the same text.suppressHydrationWarning unless you have confirmed the mismatch is harmless.onClick works. For critical actions, use a real form submit that works without JavaScript, or render an honest pending state until hydration finishes.For React's general hydration behavior, see hydrateRoot.
When an input points to a label or error message by id, the id must be the same on the server and client. It must also be unique on the page. If it changes during hydration, screen readers can lose the label or description.
This is easy to break in React on Rails because the same component can be mounted more than once with react_component.
Do not hard-code IDs inside reusable islands. Do not generate IDs with Math.random() or a module-level counter.
Use React's useId for IDs that must match SSR and hydration.
import { useId } from 'react';
export default function EmailField({ error }) {
const id = useId();
const errorId = `${id}-error`;
return (
<>
<label htmlFor={id}>Email</label>
<input
id={id}
type="email"
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && <p id={errorId}>{error}</p>}
</>
);
}
With the open-source package, useId is not enough when you mount the same component more than once on a page. useId keeps an id stable between the server render and hydration within one mount, but React only guarantees uniqueness across separate roots when each root is given a distinct identifierPrefix. The open-source React on Rails package does not set a per-mount identifierPrefix, so two mounts of the same component can produce the same useId value (for example «r0») and collide. (React on Rails Pro sets identifierPrefix to the container DOM id automatically on the default RSC-provider path — when RSC support is enabled — so useId is already safe there. On any other path, follow the open-source guidance below and pass an explicit prefix; doing so is always safe regardless of tier.)
When a component can appear more than once on a page, pass a unique prefix into it — the container id you set with the top-level id: option (section 2) works well — and build your ARIA ids from that. Use the same value for id: and the prefix prop so they stay in sync:
<%# Each mount gets a unique id; the same value is threaded in as the prefix prop.
Prop keys are passed through verbatim (React on Rails does not camelize them),
so use the same `idPrefix` key the component reads. %>
<%= react_component("EmailField", props: { idPrefix: "signup-email" }, id: "signup-email") %>
<%= react_component("EmailField", props: { idPrefix: "contact-email" }, id: "contact-email") %>
export default function EmailField({ idPrefix, error }) {
const errorId = `${idPrefix}-email-error`;
// ...use `${idPrefix}-email` for the input id, etc.
}
When you use stream_react_component, React on Rails Pro can send server-rendered HTML in pieces as work finishes. Screen readers still read the DOM in logical order, not your loading plan.
Fix this by making each streamed Suspense boundary match the reading order of the page.
Guidance:
aria-busy while content is still pending.aria-live — that queues the entire subtree for announcement and overwhelms screen-reader users. Instead, announce a short message like "Results loaded" in the page's shared live region (section 9) once the content commits.aria-live for small, deliberately announced status text, and set that text after the real content commits so the announcement is not missed or repeated.function ResultsRegion({ loading, children }) {
// `aria-busy` signals loading; a plain <div> avoids implying a landmark.
return (
<div aria-busy={loading ? 'true' : undefined}>
{loading ? <div aria-hidden="true" className="skeleton" /> : children}
</div>
);
}
When part of the page is a React Server Component, that part can render HTML but cannot run browser effects. If focus movement, keyboard handlers, or live-region updates live only in a server component, they will not run in the browser.
Fix this by putting browser behavior in client components and keeping server components for static, semantic HTML.
Guidance:
The accessibility guidance above is what is specific to the server/client split. For the RSC helper names, the config flag that enables RSC mode, and how to register server vs. client components — which are version-dependent — see the React on Rails Pro React Server Components docs.
When a React island changes routes without a full page load, the browser does not automatically announce a new page. Focus may stay on a link or button from the old view.
Fix this with the standard SPA pattern:
document.title.<h1 tabIndex={-1}> or <main tabIndex={-1}>.aria-live="polite" route announcer.What is specific to React on Rails is where this code lives.
turbo:load event.A minimal announcer inside a router island, reusing the shared live region from section 9:
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom'; // or your router's equivalent
// Inside a React Router / TanStack Router island
function RouteAnnouncer() {
const { pathname } = useLocation(); // or the router's location hook
const firstRender = useRef(true);
useEffect(() => {
// Skip the initial mount during hydration — only announce real navigations,
// and do not steal focus on first page load (see "SSR and hydration").
if (firstRender.current) {
firstRender.current = false;
return undefined;
}
const region = document.getElementById('app-live-region');
if (!region) return undefined;
// Clear first, then set on the next tick so the change is announced.
region.textContent = '';
const rafId = requestAnimationFrame(() => {
region.textContent = `Navigated to ${document.title}`;
// Move focus after the new content has painted.
document.querySelector('main')?.focus(); // <main tabIndex={-1}>
});
return () => cancelAnimationFrame(rafId);
}, [pathname]);
return null;
}
For the general SPA pattern, see Gatsby's user testing of accessible client-side routing and Deque's SPA accessibility tips.
When Rails validates a form on the server and React renders the fields, errors can land in the wrong place or lose their label relationship.
Fix this by passing Rails errors into the island once, then rendering one accessible error UI in React.
Guidance:
errors hash to field-level errors and one top-of-form summary.for and id.aria-describedby pointed at the error message after hydration.idPrefix prop (section 4) instead of relying on useId alone, so the OSS path does not produce colliding field IDs.function NameField({ idPrefix, value, error }) {
const id = `${idPrefix}-name`;
const errorId = `${id}-error`;
return (
<div>
<label htmlFor={id}>Name</label>
<input
id={id}
name="name"
defaultValue={value}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
/>
{error && <p id={errorId}>{error}</p>}
</div>
);
}
For the general form rules, see the WAI forms tutorial.
When Rails flash messages and React toasts each create their own live region, screen readers may announce the same message twice or miss one.
Fix this by creating one persistent live region in the Rails layout. Rails can render the first message there, and React islands can update the same region later.
<%# Seed any first-load flash so it is present in the initial HTML response %>
<div id="app-live-region" role="status" aria-atomic="true">
<%= flash[:notice] || flash[:alert] %>
</div>
role="status" already implies aria-live="polite", so that is not repeated. aria-atomic="true" is set explicitly so screen readers announce the whole message rather than only the changed text node.
If the region holds only screen-reader announcements (not visible text), hide it visually — not from assistive technology. display: none and visibility: hidden suppress announcements; use the clip pattern instead:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
If the same element also renders visible flash messages, leave it visible — no hiding needed.
Guidance:
region.textContent = ''; requestAnimationFrame(() => { region.textContent = message; })). Some older screen readers (e.g. JAWS, NVDA in certain modes) do not announce text injected into a region that was empty on first load; the clear-then-set pattern avoids that silent failure.role="alert" only for urgent messages that require interruption.For general live-region behavior, see MDN on live regions.
When Rails caches HTML around a react_component call, it can also cache accessible names, ARIA attributes, and visible controls. If the cache key is too broad, screen readers get stale or wrong information.
Fix this by including every accessibility-affecting input in the cache key, or by keeping that state out of the cached fragment.
Cache keys must include anything that affects:
Do not cache per-request or interactive ARIA state:
aria-expandedaria-pressedaria-selectedaria-currentBefore caching a fragment, ask: does any label, role, ARIA attribute, or visible control depend on user, request, locale, or feature flag state? If yes, put that input in the key or render that piece outside the cache.
When Rails and React choose locale or direction separately, the server HTML can say one thing and the hydrated client can replace it with another. Screen readers may pronounce text with the wrong language rules, and React may hit hydration mismatches.
Fix this by using one request-level source for locale and direction.
Guidance:
<html lang> and dir in the Rails layout from the request locale.railsContext in render-functions when a component needs request-level data such as locale.In production, prefer a single source of truth for direction — many i18n setups
expose it (for example rails-i18n locale files carry direction metadata), and a
shared helper avoids duplicating a language list. The snippet below is a minimal,
non-exhaustive illustration; the rtl_subtags list omits many RTL locales
(ks, ku-Arab, pa-Arab, …) and should not be copied verbatim into an app
that needs broad coverage.
<%# Minimal example only — derive `dir` from your i18n metadata in real apps. %>
<% rtl_subtags = %w[ar he fa ur yi ug dv ps sd ckb] %>
<% primary_subtag = I18n.locale.to_s.split(/[-_]/).first # handles ar-EG and ar_EG %>
<%= react_component(
"LocalizedNav",
props: {
locale: I18n.locale.to_s,
dir: rtl_subtags.include?(primary_subtag) ? "rtl" : "ltr"
},
prerender: true
) %>
For general RTL and dir behavior, see MDN on dir.
When a React on Rails page uses SSR, the first HTML response and the hydrated browser page can have different accessibility bugs. Testing only the hydrated React tree misses the no-JS baseline.
Fix this by testing both states.
Guidance:
prerender: true.stream_react_component, wait for the streamed content to finish before asserting.Use tools such as jest-axe, vitest-axe, axe-core, pa11y, Lighthouse, Capybara system tests, or Playwright with @axe-core/playwright. Add manual keyboard and screen reader passes for streaming, navigation, dialogs, and live-region flows.
When an accessibility bug appears only after the JavaScript bundle loads, debug the Rails output and the hydrated React output separately.
| Symptom | Likely cause | Where to look |
|---|---|---|
| Screen reader reads stale or duplicated labels | Duplicate IDs from a component mounted more than once | Section 4; pass a per-mount idPrefix (useId alone collides across roots on the OSS package and every Pro path except the default RSC-provider path, which sets identifierPrefix automatically) |
aria-describedby points at nothing after load | ID differs between server and client | Non-deterministic ID generation |
| Button is announced but does nothing | Control rendered before hydration attached handlers | Section 3; add no-JS fallback or pending state |
| Hydration warning and visual flicker | Server markup differs from client markup | Dates, random values, locale, browser-only branches |
| Streamed content is read in the wrong order | DOM order differs from logical reading order | Section 5; align Suspense boundaries |
| Announcement is missed or doubled while streaming | Live-region text changed before content committed, or changed twice | Section 5; update after commit and guard repeats |
| Content is missing for no-JS users | prerender: false, or SSR failed and the page fell back to client-only output | Sections 1 and 3; check the Rails log for ReactOnRails::PrerenderError and the Node render-server output (stdout of the JS server process). config.raise_on_prerender_error (on by default in development) surfaces these failures instead of silently falling back to client-only output. |
Portals and modals (SSR note). If a dialog's DOM is created only after hydration, keyboard and screen reader users do not get a usable dialog in the first response. Fix this by not showing the dialog until JavaScript is ready, or by rendering an accessible non-portal fallback. After hydration, follow the APG dialog pattern or use a vetted dialog component.
Short checklist: follow the links for the actual rules. This page does not restate them.
prefers-reduced-motion, target size.