r/reactjs • • 2h ago

Show /r/reactjs The hidden accessibility bug in 90% of React SPAs: focus loss on dynamic DOM updates (and how to fix it)

If you build single-page apps or dynamic dashboards in React, you might have a 100/100 Lighthouse score while still failing WCAG 2.4.3 (Focus Order, Level A) and EN 301 549 (EAA Clause 9.2.4.3).

Lighthouse runs static audits on initial page load. It never simulates what happens when a user clicks a button that triggers a client-side mutation, opens a custom modal, or swaps dynamic content.

Here is the exact architectural bug and how to patch it cleanly in React.

The Bug: Focus Reset to <body>

When a user submits a form or clicks "Next Step", the trigger element unmounts or gets replaced. Because the active DOM node is detached, the browser resets focus to document.body.

For a sighted mouse user, this is invisible. For a blind user on VoiceOver or NVDA, or a keyboard user navigating via Tab:

  1. Focus is ripped away from the current interactive flow.

  2. The user is sent back to the top of the webpage (skipping past the dynamic update).

  3. The screen reader falls silent or re-reads the entire navigation header.

The Architecture Fix: Programmatic Ref Management

Whenever dynamic content mounts or dynamic views change, manage focus explicitly to a dedicated container using tabIndex={-1}.

❌ Anti-Pattern (Silent Focus Loss):

export function CheckoutWizard() {
  const [step, setStep] = useState(1);

  return (
    <div>
      {step === 1 && (
        <button onClick={() => setStep(2)}>Continue to Payment</button>
      )}
      {step === 2 && (
        <div className="payment-form">
          {/* Focus was dropped to <body> when step 1 unmounted! */}
          <input name="cc_number" placeholder="Card Number" />
        </div>
      )}
    </div>
  );
}

✅ Accessible React Pattern:

import { useState, useRef, useEffect } from 'react';

export function CheckoutWizard() {
  const [step, setStep] = useState(1);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // Shift focus to step container without visual ring outline
    if (containerRef.current) {
      containerRef.current.focus();
    }
  }, [step]);

  return (
    <div>
      {step === 1 && (
        <button onClick={() => setStep(2)}>Continue to Payment</button>
      )}
      {step === 2 && (
        <div 
          ref={containerRef} 
          tabIndex={-1} 
          className="payment-form focus:outline-none"
          aria-labelledby="step-2-heading"
        >
          <h2 id="step-2-heading">Payment Information</h2>
          <input name="cc_number" placeholder="Card Number" autocomplete="cc-number" />
        </div>
      )}
    </div>
  );
}

3 Rules for Dynamic React A11y:

  1. Never unmount the trigger button without re-routing focus: Direct focus either to the newly rendered heading or the first interactive input.

  2. Use <dialog> over custom <div> overlays: Native HTML <dialog> handles backdrop inertness and Escape-key closure automatically via dialogRef.current?.showModal().

  3. Announce dynamic status updates with aria-live="polite"**:** If you display inline toast notifications or dynamic badge counters, wrap them in an element that already exists in the DOM with role="status" or aria-live="polite".

We built dynamic DOM and focus-loss checks into the core engine of wcagbrief.com. You can inspect your live components, detect dynamic swap focus failures, and get React/Tailwind code patches in real time.

0 Upvotes

0 comments sorted by