DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Accessible React: ARIA, Keyboard Navigation, and Screen Readers

Accessible React: ARIA, Keyboard Navigation, and Screen Readers

Accessibility isn't a nice-to-have — it expands your user base, improves SEO, and avoids legal risk. Here's what actually matters.

Semantic HTML First

// WRONG: div soup
<div onClick={handleSubmit} className='btn'>Submit</div>

// RIGHT: semantic element — keyboard accessible by default
<button type='submit' onClick={handleSubmit}>Submit</button>

// Navigation
<nav aria-label='Main navigation'>
  <ul>
    <li><a href='/'>Home</a></li>
    <li><a href='/about'>About</a></li>
  </ul>
</nav>

// Sections
<main>
  <h1>Page Title</h1>
  <section aria-label='Features'>
    <h2>Features</h2>
  </section>
</main>
Enter fullscreen mode Exit fullscreen mode

ARIA When HTML Isn't Enough

// Button that looks like a div
<div
  role='button'
  tabIndex={0}
  aria-label='Close dialog'
  onClick={onClose}
  onKeyDown={(e) => e.key === 'Enter' && onClose()}
>
  ×
</div>

// Loading state
<button aria-busy={isLoading} disabled={isLoading}>
  {isLoading ? 'Saving...' : 'Save'}
</button>

// Error messages
<input
  id='email'
  aria-describedby='email-error'
  aria-invalid={!!errors.email}
/>
<p id='email-error' role='alert'>{errors.email}</p>
Enter fullscreen mode Exit fullscreen mode

Focus Management

import { useRef, useEffect } from 'react';

function Modal({ isOpen, onClose, children }) {
  const closeButtonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (isOpen) {
      // Move focus into modal when it opens
      closeButtonRef.current?.focus();
    }
  }, [isOpen]);

  return (
    <dialog
      open={isOpen}
      aria-modal='true'
      aria-label='Settings'
      onKeyDown={(e) => e.key === 'Escape' && onClose()}
    >
      <button ref={closeButtonRef} onClick={onClose}>Close</button>
      {children}
    </dialog>
  );
}
Enter fullscreen mode Exit fullscreen mode

Skip Navigation

// First element on page — lets keyboard users skip nav
<a
  href='#main-content'
  className='sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:bg-white focus:p-4'
>
  Skip to main content
</a>

<main id='main-content'>
  {/* page content */}
</main>
Enter fullscreen mode Exit fullscreen mode

Color Contrast

WCAG AA requires:
- Normal text: 4.5:1 contrast ratio
- Large text (18px+): 3:1
- UI components: 3:1

Check with: https://webaim.org/resources/contrastchecker/
Enter fullscreen mode Exit fullscreen mode

Testing Tools

# axe-core — automated a11y testing
npm install --save-dev @axe-core/react

# eslint-plugin-jsx-a11y — catch issues in code
npm install --save-dev eslint-plugin-jsx-a11y
Enter fullscreen mode Exit fullscreen mode
// In Vitest/Jest
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';

test('Button has no a11y violations', async () => {
  const { container } = render(<Button>Click me</Button>);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});
Enter fullscreen mode Exit fullscreen mode

shadcn/ui (used in the AI SaaS Starter Kit) is built on Radix UI — fully accessible primitives with ARIA and keyboard navigation pre-built. $99 at whoffagents.com.

Top comments (0)