This page is part of the Fallback UI Rendering Patterns guide, one section of the broader Frontend Error Boundary Architecture Fundamentals coverage of how boundaries catch, render, and recover from render-time failures.

The exact failure this page solves #

An error boundary catches a render exception and swaps the crashed subtree for a fallback. Visually this works fine — sighted users see a message and a retry button appear instantly. But a screen-reader user hears nothing. The DOM changed underneath them with no announcement, so unless they happen to be tabbed onto the exact element that changed, the failure is invisible. Worse, many fallback implementations either steal keyboard focus on every single re-render (making the retry button unreachable because focus resets mid-interaction) or never move focus at all, leaving the user’s cursor sitting on a control that no longer exists in the accessibility tree.

The fix requires three coordinated pieces: a live region that is already present in the DOM before the swap happens, a focus-management effect that moves focus exactly once per new error, and a retry control that behaves like a normal interactive element rather than a silently-disabled one. Get any one of these wrong and the fallback is either silent, unreachable, or actively hostile to keyboard and screen-reader users.

Zero-to-working implementation #

The component below is a complete, drop-in fallback for a React error boundary. It handles announcement, one-shot focus management, and an accessible retry action.

// AccessibleErrorFallback.tsx
import { useEffect, useRef } from 'react';

interface Props {
  error: Error;
  onRetry: () => void;
  retryCount: number;
}

export function AccessibleErrorFallback({ error, onRetry, retryCount }: Props) {
  const headingRef = useRef<HTMLHeadingElement>(null);
  const lastAnnouncedError = useRef<Error | null>(null);

  useEffect(() => {
    // Guard against focus-stealing on every parent re-render: only move focus
    // when this is a genuinely NEW error instance, not a repeat render of the
    // same caught error (which happens whenever a sibling re-renders).
    if (lastAnnouncedError.current === error) return;
    lastAnnouncedError.current = error;
    headingRef.current?.focus();
  }, [error]);

  return (
    <div
      role="alert"
      aria-atomic="true"
      className="error-fallback"
      data-testid="error-fallback"
    >
      <h2 ref={headingRef} tabIndex={-1} className="error-fallback__heading">
        Something went wrong loading this section
      </h2>
      <p className="error-fallback__detail">
        {retryCount > 2
          ? 'This keeps failing. Check your connection, then try again.'
          : 'You can retry the action below without reloading the page.'}
      </p>
      <button type="button" onClick={onRetry} className="error-fallback__retry">
        Retry
      </button>
    </div>
  );
}

The live-region container itself must exist in the boundary’s markup before the error state flips — mount an always-present wrapper <div role="alert"> and toggle its children, rather than conditionally rendering the whole <div> only after hasError becomes true. Assistive tech tracks the region by its presence in the accessibility tree; a region that appears at the same instant its content changes is not guaranteed to be announced.

Step-by-step explanation #

  1. The live region must already be tracked. role="alert" creates an implicit aria-live="assertive" region. Screen readers only pick up mutations inside a region they were already watching, so the boundary should render the wrapper <div> unconditionally and swap its inner content, not mount the entire alert node fresh on error.

  2. aria-atomic="true" reads the whole message. Without it, some screen readers only announce the specific text node that changed, which can clip a status update to a fragment. Setting aria-atomic forces the full region content to be read as one unit.

  3. Focus moves to the heading exactly once per error. tabIndex={-1} makes the <h2> programmatically focusable without adding it to the natural tab order. The lastAnnouncedError ref compares the current error instance before calling focus(), so a parent re-render that passes the same error object again does not yank focus a second time — this is the difference between “focus lands once, user can then tab to Retry” and “focus resets every 200ms and the retry button is never reachable.”

  4. The retry button stays a native, always-enabled <button>. No custom role, no aria-pressed. If a pending retry state is needed, toggle aria-disabled="true" and add a visible/announced status line rather than the native disabled attribute, which removes the element from the tab order entirely and gives screen-reader users no explanation for why it vanished.

  5. Transitions respect motion preference. Any CSS entrance animation on .error-fallback should be wrapped in @media (prefers-reduced-motion: no-preference) so the swap is instant for users who have reduced motion enabled at the OS level — a slide-in or fade can otherwise trigger discomfort for vestibular-sensitive users at the exact moment they are already dealing with a failure.

  6. Copy avoids color as the only signal. The heading text and an icon (if used) both carry the “error” meaning; color alone — a red border, for instance — is invisible to screen-reader users and unreliable for colorblind sighted users.

/* error-fallback.css */
.error-fallback {
  border: 2px solid currentColor; /* shape + border, not color alone */
}

@media (prefers-reduced-motion: no-preference) {
  .error-fallback {
    animation: fallback-in 180ms ease-out;
  }
}

@keyframes fallback-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}

Edge cases #

Scenario Symptom Mitigation
role="alert" on a high-frequency error (e.g. polling failure every 2s) Screen reader repeats the same announcement constantly, drowning out other content Switch to aria-live="polite" for recoverable, low-severity errors, and de-duplicate identical consecutive messages before re-announcing
Focus effect keyed on the wrong dependency Focus resets to the heading on every unrelated parent re-render, making the retry button unreachable via keyboard Compare the error instance in a ref (as shown above) and only call focus() when it differs from the last one handled
Live region mounted conditionally with the error content First error of a session is never announced even though later ones are Render the role="alert" wrapper unconditionally in the boundary’s initial markup; toggle only the inner text/children
Fallback relies on color alone (red text/border) to signal failure Screen-reader and colorblind users miss that anything failed Pair color with text copy, an accessible icon with aria-hidden="true", and sufficient contrast (4.5:1 minimum for body text)

Verification steps #

  1. VoiceOver (macOS/Safari). Enable VoiceOver, trigger the error boundary, and confirm you hear the heading text announced immediately without needing to tab anywhere. Then press Tab once and confirm focus lands on the Retry button.

  2. NVDA (Windows/Firefox or Chrome). Repeat the same trigger with NVDA running in browse mode. Confirm the alert is announced and that pressing Tab from the announcement moves to the retry control, not back to the top of the page.

  3. Automated axe scan. Run axe.run(document.querySelector('[data-testid="error-fallback"]')) (or the Playwright @axe-core/playwright integration) against the mounted fallback and confirm zero violations, particularly for aria-hidden-focus and color-contrast.

  4. Keyboard tab order. With the mouse untouched, trigger the error, then press Tab repeatedly. The order should be: fallback heading (focused programmatically, not via Tab) → Retry button → next focusable element after the boundary. Shift+Tab from Retry should return to the heading, not escape the fallback entirely.

  5. Repeat-error regression check. Force the same error twice in a row (e.g. click Retry and have it fail again) and confirm focus does not jump back to the heading a second time if the error instance is unchanged — only a genuinely new error should re-trigger the focus move.

Frequently asked questions #

Should the error fallback use an assertive alert or a polite live region? Use role="alert" (assertive) when the failure blocks the user’s current task, such as a crashed primary widget or a failed submission. Use aria-live="polite" for secondary, non-blocking failures — a sidebar module that fails to load, for instance — so the announcement waits for a natural pause instead of interrupting whatever the screen reader is currently speaking.

Why does focus keep jumping back to the fallback on every re-render? This happens when the focus-management effect runs on every render or watches a dependency that changes identity each time, such as an inline error object recreated by the parent. Track the last error you already focused for in a ref, compare it before calling focus(), and only move focus when the error instance is genuinely new — otherwise a parent re-render (unrelated to the error) resets keyboard focus and makes the retry button practically unreachable.

Does the retry button need any special ARIA attributes? No — a native <button> with clear visible text like “Retry” needs nothing extra. If you need a pending state while a retry is in flight, use aria-disabled="true" plus a visible and announced status message, rather than the native disabled attribute, which removes the control from the tab order and leaves screen-reader users with no explanation for its disappearance.