Accessibility in React: Beyond the Basics

12 min read2391 words

Most React developers know the basics: use semantic HTML, add alt text to images, and include ARIA labels. But building truly accessible applications requires mastering advanced patterns that go far beyond these fundamentals.

In my experience building accessible React applications for enterprise clients, I've discovered that the real challenges emerge when dealing with complex interactions, dynamic content, and custom components. Here's what I've learned about advanced accessibility techniques that actually work in production.

The Focus Management Problem

One of the biggest accessibility challenges I encounter is managing focus in dynamic React applications. Users navigating with keyboards or screen readers rely on predictable focus behavior, but React's component-based architecture can easily break this contract.

Consider this modal implementation that looks correct but creates accessibility problems:

// components/Modal.tsx - Problematic version
const Modal = ({ isOpen, onClose, children }) => {
  if (!isOpen) return null;
  
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <button className="close-button" onClick={onClose}>×</button>
        {children}
      </div>
    </div>
  );
};

This modal has several issues: focus isn't trapped inside the modal, the Escape key doesn't work, and when closed, focus disappears entirely. Here's how I solve these problems:

// components/Modal.tsx - Accessible version
import { useEffect, useRef, useCallback } from 'react';
 
const Modal = ({ isOpen, onClose, title, children }) => {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);
  const firstFocusableRef = useRef<HTMLButtonElement>(null);
 
  const trapFocus = useCallback((e: KeyboardEvent) => {
    if (!modalRef.current) return;
    
    const focusableElements = modalRef.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    
    const firstElement = focusableElements[0] as HTMLElement;
    const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
    
    if (e.key === 'Tab') {
      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement.focus();
        }
      }
    }
  }, []);
 
  const handleEscape = useCallback((e: KeyboardEvent) => {
    if (e.key === 'Escape') {
      onClose();
    }
  }, [onClose]);
 
  useEffect(() => {
    if (isOpen) {
      // Store the currently focused element
      previousFocusRef.current = document.activeElement as HTMLElement;
      
      // Focus the first focusable element in the modal
      setTimeout(() => {
        firstFocusableRef.current?.focus();
      }, 0);
      
      // Add event listeners
      document.addEventListener('keydown', trapFocus);
      document.addEventListener('keydown', handleEscape);
      
      // Prevent background scrolling
      document.body.style.overflow = 'hidden';
    }
    
    return () => {
      if (isOpen) {
        document.removeEventListener('keydown', trapFocus);
        document.removeEventListener('keydown', handleEscape);
        document.body.style.overflow = '';
        
        // Restore focus to the previous element
        if (previousFocusRef.current) {
          previousFocusRef.current.focus();
        }
      }
    };
  }, [isOpen, trapFocus, handleEscape]);
 
  if (!isOpen) return null;
 
  return (
    <div 
      className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
      onClick={onClose}
    >
      <div
        ref={modalRef}
        className="bg-white rounded-lg p-6 max-w-md w-full mx-4"
        onClick={(e) => e.stopPropagation()}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
      >
        <div className="flex justify-between items-center mb-4">
          <h2 id="modal-title" className="text-xl font-semibold">
            {title}
          </h2>
          <button
            ref={firstFocusableRef}
            onClick={onClose}
            className="text-gray-500 hover:text-gray-700"
            aria-label="Close dialog"
          >
            ×
          </button>
        </div>
        {children}
      </div>
    </div>
  );
};

This implementation properly traps focus, handles the Escape key, and restores focus when the modal closes. The aria-modal="true" attribute tells screen readers that this is a modal dialog, and the aria-labelledby connects the title to the dialog for screen reader users.

Advanced ARIA Patterns with Compound Components

Building accessible compound components requires careful coordination between multiple elements. I've found that the most effective approach is to use React context to share accessibility state between components.

Here's a tabs component that demonstrates advanced ARIA patterns:

// components/Tabs/TabsContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
 
interface TabsContextType {
  activeTab: string;
  setActiveTab: (tabId: string) => void;
  orientation: 'horizontal' | 'vertical';
  tabs: Map<string, string>;
  registerTab: (id: string, label: string) => void;
}
 
const TabsContext = createContext<TabsContextType | null>(null);
 
export const useTabsContext = () => {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error('Tabs components must be used within TabsProvider');
  }
  return context;
};
 
// components/Tabs/Tabs.tsx
interface TabsProps {
  children: ReactNode;
  defaultTab: string;
  orientation?: 'horizontal' | 'vertical';
}
 
export const Tabs = ({ 
  children, 
  defaultTab, 
  orientation = 'horizontal' 
}: TabsProps) => {
  const [activeTab, setActiveTab] = useState(defaultTab);
  const [tabs] = useState(new Map<string, string>());
  
  const registerTab = (id: string, label: string) => {
    tabs.set(id, label);
  };
  
  return (
    <TabsContext.Provider value={{
      activeTab,
      setActiveTab,
      orientation,
      tabs,
      registerTab
    }}>
      <div className="tabs">
        {children}
      </div>
    </TabsContext.Provider>
  );
};
 
// components/Tabs/TabList.tsx
export const TabList = ({ children }: { children: ReactNode }) => {
  const { orientation } = useTabsContext();
  
  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const tabs = Array.from(e.currentTarget.querySelectorAll('[role="tab"]'));
    const currentIndex = tabs.indexOf(document.activeElement as HTMLElement);
    
    let nextIndex = currentIndex;
    
    switch (e.key) {
      case 'ArrowRight':
        if (orientation === 'horizontal') {
          e.preventDefault();
          nextIndex = (currentIndex + 1) % tabs.length;
        }
        break;
      case 'ArrowLeft':
        if (orientation === 'horizontal') {
          e.preventDefault();
          nextIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
        }
        break;
      case 'ArrowDown':
        if (orientation === 'vertical') {
          e.preventDefault();
          nextIndex = (currentIndex + 1) % tabs.length;
        }
        break;
      case 'ArrowUp':
        if (orientation === 'vertical') {
          e.preventDefault();
          nextIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
        }
        break;
      case 'Home':
        e.preventDefault();
        nextIndex = 0;
        break;
      case 'End':
        e.preventDefault();
        nextIndex = tabs.length - 1;
        break;
    }
    
    if (nextIndex !== currentIndex) {
      (tabs[nextIndex] as HTMLElement).focus();
    }
  };
  
  return (
    <div
      role="tablist"
      aria-orientation={orientation}
      className={`flex ${orientation === 'vertical' ? 'flex-col' : 'flex-row'}`}
      onKeyDown={handleKeyDown}
    >
      {children}
    </div>
  );
};
 
// components/Tabs/Tab.tsx
interface TabProps {
  id: string;
  children: ReactNode;
}
 
export const Tab = ({ id, children }: TabProps) => {
  const { activeTab, setActiveTab, registerTab } = useTabsContext();
  
  useEffect(() => {
    registerTab(id, typeof children === 'string' ? children : id);
  }, [id, children, registerTab]);
  
  const isSelected = activeTab === id;
  
  return (
    <button
      role="tab"
      id={`tab-${id}`}
      aria-selected={isSelected}
      aria-controls={`tabpanel-${id}`}
      tabIndex={isSelected ? 0 : -1}
      className={`px-4 py-2 border-b-2 ${
        isSelected 
          ? 'border-blue-500 text-blue-600' 
          : 'border-transparent text-gray-500 hover:text-gray-700'
      }`}
      onClick={() => setActiveTab(id)}
    >
      {children}
    </button>
  );
};
 
// components/Tabs/TabPanel.tsx
export const TabPanel = ({ id, children }: { id: string; children: ReactNode }) => {
  const { activeTab } = useTabsContext();
  const isActive = activeTab === id;
  
  return (
    <div
      role="tabpanel"
      id={`tabpanel-${id}`}
      aria-labelledby={`tab-${id}`}
      hidden={!isActive}
      className="mt-4 p-4 border rounded-lg"
    >
      {isActive && children}
    </div>
  );
};

This tabs implementation handles keyboard navigation, proper ARIA relationships, and focus management. The context pattern ensures all components stay synchronized without prop drilling.

Screen Reader Optimization

Beyond basic ARIA attributes, optimizing for screen readers requires understanding how they interpret content and providing the right information at the right time.

One technique I use frequently is live regions for dynamic content updates:

// components/LiveRegion.tsx
import { useEffect, useRef } from 'react';
 
interface LiveRegionProps {
  message: string;
  type?: 'polite' | 'assertive';
  clearDelay?: number;
}
 
export const LiveRegion = ({ 
  message, 
  type = 'polite', 
  clearDelay = 5000 
}: LiveRegionProps) => {
  const regionRef = useRef<HTMLDivElement>(null);
  
  useEffect(() => {
    if (message && regionRef.current) {
      // Clear the region first to ensure the message is announced
      regionRef.current.textContent = '';
      
      // Add the message after a brief delay
      setTimeout(() => {
        if (regionRef.current) {
          regionRef.current.textContent = message;
        }
      }, 100);
      
      // Clear the message after the specified delay
      const clearTimer = setTimeout(() => {
        if (regionRef.current) {
          regionRef.current.textContent = '';
        }
      }, clearDelay);
      
      return () => clearTimeout(clearTimer);
    }
  }, [message, clearDelay]);
  
  return (
    <div
      ref={regionRef}
      aria-live={type}
      aria-atomic="true"
      className="sr-only"
    />
  );
};
 
// Usage in a form component
const ContactForm = () => {
  const [status, setStatus] = useState('');
  const [errors, setErrors] = useState<Record<string, string>>({});
  
  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    
    try {
      // Form submission logic
      await submitForm(formData);
      setStatus('Form submitted successfully');
      setErrors({});
    } catch (error) {
      setStatus('Form submission failed. Please check the errors below.');
      setErrors(validationErrors);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <LiveRegion message={status} type="polite" />
      
      <div className="mb-4">
        <label htmlFor="email" className="block text-sm font-medium mb-1">
          Email Address
        </label>
        <input
          type="email"
          id="email"
          name="email"
          aria-describedby={errors.email ? 'email-error' : undefined}
          aria-invalid={!!errors.email}
          className={`w-full px-3 py-2 border rounded ${
            errors.email ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {errors.email && (
          <div id="email-error" role="alert" className="text-red-600 text-sm mt-1">
            {errors.email}
          </div>
        )}
      </div>
      
      <button
        type="submit"
        className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
      >
        Submit
      </button>
    </form>
  );
};

The LiveRegion component ensures that status messages are announced to screen readers without interrupting the user's current focus. Using aria-atomic="true" makes sure the entire message is read, not just the changed part.

Advanced Testing Strategies

Automated testing catches many accessibility issues, but manual testing with actual assistive technology reveals problems that tools miss. Here's my testing approach:

// __tests__/Modal.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Modal } from '../Modal';
 
describe('Modal Accessibility', () => {
  const user = userEvent.setup();
  
  test('traps focus within modal', async () => {
    const onClose = jest.fn();
    
    render(
      <div>
        <button>Outside Button</button>
        <Modal isOpen={true} onClose={onClose} title="Test Modal">
          <input placeholder="First input" />
          <input placeholder="Second input" />
          <button>Modal Button</button>
        </Modal>
      </div>
    );
    
    // Modal should focus the first focusable element
    await waitFor(() => {
      expect(screen.getByLabelText('Close dialog')).toHaveFocus();
    });
    
    // Tab should cycle through modal elements only
    await user.tab(); // -> First input
    expect(screen.getByPlaceholderText('First input')).toHaveFocus();
    
    await user.tab(); // -> Second input
    expect(screen.getByPlaceholderText('Second input')).toHaveFocus();
    
    await user.tab(); // -> Modal button
    expect(screen.getByRole('button', { name: 'Modal Button' })).toHaveFocus();
    
    await user.tab(); // -> Should wrap to close button
    expect(screen.getByLabelText('Close dialog')).toHaveFocus();
  });
  
  test('closes on Escape key', async () => {
    const onClose = jest.fn();
    
    render(
      <Modal isOpen={true} onClose={onClose} title="Test Modal">
        <p>Modal content</p>
      </Modal>
    );
    
    await user.keyboard('{Escape}');
    expect(onClose).toHaveBeenCalledTimes(1);
  });
  
  test('restores focus when closed', async () => {
    const TestComponent = () => {
      const [isOpen, setIsOpen] = useState(false);
      return (
        <div>
          <button onClick={() => setIsOpen(true)}>Open Modal</button>
          <Modal 
            isOpen={isOpen} 
            onClose={() => setIsOpen(false)} 
            title="Test Modal"
          >
            <p>Content</p>
          </Modal>
        </div>
      );
    };
    
    render(<TestComponent />);
    
    const openButton = screen.getByRole('button', { name: 'Open Modal' });
    
    // Open modal
    await user.click(openButton);
    
    // Close modal
    await user.keyboard('{Escape}');
    
    // Focus should return to open button
    await waitFor(() => {
      expect(openButton).toHaveFocus();
    });
  });
  
  test('has correct ARIA attributes', () => {
    render(
      <Modal isOpen={true} onClose={() => {}} title="Test Modal">
        <p>Modal content</p>
      </Modal>
    );
    
    const dialog = screen.getByRole('dialog');
    expect(dialog).toHaveAttribute('aria-modal', 'true');
    expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title');
    
    const title = screen.getByRole('heading');
    expect(title).toHaveAttribute('id', 'modal-title');
  });
});

I also use axe-core for automated testing:

// __tests__/setup.ts
import { configureAxe, toHaveNoViolations } from 'jest-axe';
 
expect.extend(toHaveNoViolations);
 
// Custom axe configuration
const axe = configureAxe({
  rules: {
    // Disable color contrast checking in tests
    'color-contrast': { enabled: false },
    // Enable additional checks
    'landmark-one-main': { enabled: true },
    'region': { enabled: true }
  }
});
 
// __tests__/accessibility.test.tsx
import { render } from '@testing-library/react';
import { axe } from './setup';
import { App } from '../App';
 
test('should not have accessibility violations', async () => {
  const { container } = render(<App />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Performance and Accessibility

Accessibility features can impact performance, but with careful implementation, you can maintain both:

// components/VirtualizedList.tsx
import { FixedSizeList, areEqual } from 'react-window';
import { memo } from 'react';
 
interface ListItemProps {
  index: number;
  style: React.CSSProperties;
  data: {
    items: any[];
    onItemClick: (item: any) => void;
  };
}
 
const ListItem = memo<ListItemProps>(({ index, style, data }) => {
  const item = data.items[index];
  
  return (
    <div
      style={style}
      role="option"
      aria-selected={false}
      aria-posinset={index + 1}
      aria-setsize={data.items.length}
      tabIndex={-1}
      onClick={() => data.onItemClick(item)}
      className="flex items-center px-4 py-2 hover:bg-gray-100 cursor-pointer"
    >
      <span className="font-medium">{item.name}</span>
      <span className="ml-2 text-gray-500">{item.description}</span>
    </div>
  );
}, areEqual);
 
interface VirtualizedListProps {
  items: any[];
  height: number;
  itemHeight: number;
  onItemClick: (item: any) => void;
}
 
export const VirtualizedList = ({ 
  items, 
  height, 
  itemHeight, 
  onItemClick 
}: VirtualizedListProps) => {
  const listRef = useRef<FixedSizeList>(null);
  const [focusedIndex, setFocusedIndex] = useState(0);
  
  const handleKeyDown = (e: KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setFocusedIndex(prev => Math.min(prev + 1, items.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setFocusedIndex(prev => Math.max(prev - 1, 0));
        break;
      case 'Home':
        e.preventDefault();
        setFocusedIndex(0);
        listRef.current?.scrollToItem(0);
        break;
      case 'End':
        e.preventDefault();
        setFocusedIndex(items.length - 1);
        listRef.current?.scrollToItem(items.length - 1);
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        onItemClick(items[focusedIndex]);
        break;
    }
  };
  
  useEffect(() => {
    if (listRef.current) {
      listRef.current.scrollToItem(focusedIndex);
    }
  }, [focusedIndex]);
  
  return (
    <div
      role="listbox"
      aria-label="Search results"
      tabIndex={0}
      onKeyDown={handleKeyDown}
      className="border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
    >
      <FixedSizeList
        ref={listRef}
        height={height}
        itemCount={items.length}
        itemSize={itemHeight}
        itemData={{ items, onItemClick }}
        className="scrollbar-thin"
      >
        {ListItem}
      </FixedSizeList>
    </div>
  );
};

This virtualized list maintains accessibility features while handling thousands of items efficiently. The key is using proper ARIA attributes (aria-posinset, aria-setsize) and keyboard navigation that works with the virtualization.

Common Pitfalls and Solutions

Through years of building accessible React applications, I've identified patterns that consistently cause problems:

Pitfall 1: Incorrect ARIA usage Many developers add ARIA attributes without understanding their purpose:

// Wrong - redundant ARIA
<button role="button" aria-label="Save">Save</button>
 
// Right - semantic HTML is sufficient
<button>Save</button>
 
// Wrong - conflicting semantics
<div role="button" onClick={handleClick}>Click me</div>
 
// Right - use proper semantic elements
<button onClick={handleClick}>Click me</button>

Pitfall 2: Focus management in SPAs Route changes often break focus flow:

// components/RouteWrapper.tsx
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
 
export const RouteWrapper = ({ children }: { children: ReactNode }) => {
  const router = useRouter();
  const skipLinkRef = useRef<HTMLAnchorElement>(null);
  
  useEffect(() => {
    const handleRouteChange = () => {
      // Focus skip link on route change
      skipLinkRef.current?.focus();
      
      // Update page title
      document.title = `${getPageTitle(router.pathname)} - My App`;
    };
    
    router.events.on('routeChangeComplete', handleRouteChange);
    return () => router.events.off('routeChangeComplete', handleRouteChange);
  }, [router]);
  
  return (
    <>
      <a
        ref={skipLinkRef}
        href="#main-content"
        className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-blue-600 text-white px-4 py-2 rounded z-50"
      >
        Skip to main content
      </a>
      {children}
    </>
  );
};

Pitfall 3: Dynamic content without announcements Loading states and error messages need proper ARIA live regions:

// hooks/useLoadingAnnouncement.ts
export const useLoadingAnnouncement = () => {
  const [announcement, setAnnouncement] = useState('');
  
  const announceLoading = useCallback((message: string) => {
    setAnnouncement(message);
    setTimeout(() => setAnnouncement(''), 3000);
  }, []);
  
  return { announcement, announceLoading };
};
 
// Usage in component
const SearchResults = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [results, setResults] = useState([]);
  const { announcement, announceLoading } = useLoadingAnnouncement();
  
  const handleSearch = async (query: string) => {
    setIsLoading(true);
    announceLoading('Searching...');
    
    try {
      const data = await searchAPI(query);
      setResults(data);
      announceLoading(`Found ${data.length} results`);
    } catch (error) {
      announceLoading('Search failed. Please try again.');
    } finally {
      setIsLoading(false);
    }
  };
  
  return (
    <div>
      <SearchInput onSearch={handleSearch} />
      <div aria-live="polite" className="sr-only">
        {announcement}
      </div>
      <div role="region" aria-label="Search results">
        {results.map(result => (
          <SearchResult key={result.id} result={result} />
        ))}
      </div>
    </div>
  );
};

Building accessible React applications requires understanding both the technical implementation and the user experience. The patterns I've shared here solve real accessibility problems I've encountered in production applications. Remember that automated testing is just the starting point – always test with actual assistive technology and consider getting feedback from users with disabilities.

The investment in accessibility pays dividends not just in inclusivity, but in code quality, user experience, and maintainability. When you build with accessibility in mind from the start, you create better applications for everyone.