Back to Blog

Building Accessible React Components with TypeScript

Web accessibility isn't optional—it's essential. In this beginner-friendly guide, you'll learn how to build accessible React components using TypeScript. Whether you're new to accessibility or React, this practical tutorial will help you create inclusive web applications that everyone can use.

Why Accessibility Matters in React

Over 1 billion people worldwide live with some form of disability. Making your React apps accessible means:

  • Wider audience - Your app works for everyone, including people using screen readers or keyboard navigation
  • Better SEO - Search engines favor accessible websites
  • Legal compliance - Many countries require web accessibility (WCAG guidelines)
  • Improved UX - Accessible design benefits all users

Setting Up TypeScript for Accessible Components

TypeScript helps catch accessibility issues at compile time. Here's how to type your accessible props:

// types.ts
          import { AriaAttributes, HTMLAttributes } from 'react';

// Extend HTML attributes with ARIA props
interface AccessibleProps extends HTMLAttributes<HTMLElement>, AriaAttributes {
  className?: string;
}

// Button with accessible props
interface ButtonProps extends AccessibleProps {
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
  onClick?: () => void;
  children: React.ReactNode;
}

Building an Accessible Button Component

Let's start with the most common component—a button. Here's how to make it fully accessible:

// Button.tsx
import React from 'react';

interface ButtonProps {
  children: React.ReactNode;
  onClick?: () => void;
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
  ariaLabel?: string;
  type?: 'button' | 'submit' | 'reset';
}

const Button: React.FC<ButtonProps> = ({
  children,
  onClick,
  variant = 'primary',
  disabled = false,
  ariaLabel,
  type = 'button',
  ...props
}) => {
  return (
    <button
      type={type}
      onClick={onClick}
      disabled={disabled}
      aria-label={ariaLabel}
      aria-disabled={disabled}
      className={`btn btn--${variant} ${disabled ? 'btn--disabled' : ''}`}
      {...props}
    >
      {children}
    </button>
  );
};

export default Button;

// Usage
<Button onClick={handleSubmit} ariaLabel="Submit form">
  Submit
</Button>

Key Accessibility Features:

  • Semantic HTML - Using real <button> instead of <div>
  • aria-label - Screen reader description
  • aria-disabled - Announces disabled state
  • type attribute - Prevents form submission bugs

Creating an Accessible Modal Dialog

Modals require special attention for accessibility. Here's a complete implementation:

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

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
  const modalRef = useRef<HTMLDivElement>(null);
  const closeButtonRef = useRef<HTMLButtonElement>(null);

  // Focus trap - keep focus inside modal
  useEffect(() => {
    if (isOpen) {
      // Focus close button when modal opens
      closeButtonRef.current?.focus();
      
      // Prevent background scroll
      document.body.style.overflow = 'hidden';
      
      return () => {
        document.body.style.overflow = '';
      };
    }
  }, [isOpen]);

  // Close on Escape key
  useEffect(() => {
    const handleEscape = (event: KeyboardEvent) => {
      if (event.key === 'Escape' && isOpen) {
        onClose();
      }
    };

    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      className="modal-overlay"
      onClick={onClose}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <div
        className="modal-content"
        onClick={(e) => e.stopPropagation()}
        ref={modalRef}
      >
        <div className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button
            ref={closeButtonRef}
            onClick={onClose}
            aria-label="Close modal"
            className="modal-close"
          >
            ✕
          </button>
        </div>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>
  );
};

export default Modal;

Accessibility Features Explained:

  • role="dialog" - Tells screen readers this is a dialog
  • aria-modal="true" - Indicates modal behavior
  • aria-labelledby - Links to the modal title
  • Focus management - Automatically focuses close button
  • Escape key - Standard keyboard shortcut to close
  • Body scroll lock - Prevents background scrolling

Building an Accessible Form

Forms are crucial for accessibility. Here's how to create an input component:

// Input.tsx
import React, { useId } from 'react';

interface InputProps {
  label: string;
  type?: string;
  value: string;
  onChange: (value: string) => void;
  error?: string;
  required?: boolean;
  placeholder?: string;
  helpText?: string;
}

const Input: React.FC<InputProps> = ({
  label,
  type = 'text',
  value,
  onChange,
  error,
  required = false,
  placeholder,
  helpText,
}) => {
  const id = useId();
  const errorId = `${id}-error`;
  const helpId = `${id}-help`;

  return (
    <div className="form-field">
      <label htmlFor={id} className="form-label">
        {label}
        {required && <span aria-label="required"> *</span>}
      </label>
      
      {helpText && (
        <p id={helpId} className="form-help">
          {helpText}
        </p>
      )}
      
      <input
        id={id}
        type={type}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder={placeholder}
        required={required}
        aria-required={required}
        aria-invalid={!!error}
        aria-describedby={
          error ? errorId : helpText ? helpId : undefined
        }
        className={`form-input ${error ? 'form-input--error' : ''}`}
      />
      
      {error && (
        <p id={errorId} className="form-error" role="alert">
          {error}
        </p>
      )}
    </div>
  );
};

export default Input;

// Usage
<Input
  label="Email Address"
  type="email"
  value={email}
  onChange={setEmail}
  required
  helpText="We'll never share your email"
  error={emailError}
/>

Form Accessibility Features:

  • Unique IDs - Using useId() for proper label association
  • aria-required - Announces required fields
  • aria-invalid - Indicates validation errors
  • aria-describedby - Links help text and errors
  • role="alert" - Screen readers announce errors immediately

Accessible Navigation with TypeScript

Navigation requires keyboard support and proper ARIA attributes:

// Navigation.tsx
import React, { useState } from 'react';

interface NavItem {
  label: string;
  href: string;
  current?: boolean;
}

interface NavigationProps {
  items: NavItem[];
}

const Navigation: React.FC<NavigationProps> = ({ items }) => {
  const [activeIndex, setActiveIndex] = useState(0);

  const handleKeyDown = (
    event: React.KeyboardEvent,
    index: number
  ) => {
    const { key } = event;
    
    if (key === 'ArrowRight') {
      event.preventDefault();
      const nextIndex = (index + 1) % items.length;
      setActiveIndex(nextIndex);
      document.getElementById(`nav-item-${nextIndex}`)?.focus();
    } else if (key === 'ArrowLeft') {
      event.preventDefault();
      const prevIndex = (index - 1 + items.length) % items.length;
      setActiveIndex(prevIndex);
      document.getElementById(`nav-item-${prevIndex}`)?.focus();
    } else if (key === 'Home') {
      event.preventDefault();
      setActiveIndex(0);
      document.getElementById('nav-item-0')?.focus();
    } else if (key === 'End') {
      event.preventDefault();
      const lastIndex = items.length - 1;
      setActiveIndex(lastIndex);
      document.getElementById(`nav-item-${lastIndex}`)?.focus();
    }
  };

  return (
    <nav aria-label="Main navigation">
      <ul role="menubar" className="nav-list">
        {items.map((item, index) => (
          <li key={item.href} role="none">
            <a
              id={`nav-item-${index}`}
              href={item.href}
              role="menuitem"
              tabIndex={index === activeIndex ? 0 : -1}
              aria-current={item.current ? 'page' : undefined}
              onKeyDown={(e) => handleKeyDown(e, index)}
              className="nav-link"
            >
              {item.label}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
};

export default Navigation;

Common ARIA Attributes Explained

Attribute Purpose Example
aria-label Provides accessible name aria-label="Close menu"
aria-labelledby References another element for label aria-labelledby="modal-title"
aria-describedby References description text aria-describedby="help-text"
aria-expanded Indicates if element is expanded aria-expanded="true"
aria-hidden Hides from screen readers aria-hidden="true"
aria-live Announces dynamic content aria-live="polite"

Testing Your Accessible Components

Here's how to test accessibility:

1. Keyboard Testing

  • Tab - Navigate through interactive elements
  • Shift + Tab - Navigate backwards
  • Enter/Space - Activate buttons
  • Escape - Close modals/dropdowns
  • Arrow keys - Navigate lists/menus

2. Screen Reader Testing

// Install React Testing Library
npm install --save-dev @testing-library/react @testing-library/jest-dom

// Button.test.tsx
import { render, screen } from '@testing-library/react';
import Button from './Button';

describe('Button Accessibility', () => {
  test('has accessible role', () => {
    render(<Button>Click me</Button>);
    const button = screen.getByRole('button', { name: 'Click me' });
    expect(button).toBeInTheDocument();
  });

  test('announces disabled state', () => {
    render(<Button disabled>Submit</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveAttribute('aria-disabled', 'true');
  });
});

3. Automated Tools

// Install axe-core
npm install --save-dev @axe-core/react

// index.tsx (development only)
if (process.env.NODE_ENV !== 'production') {
  import('@axe-core/react').then(axe => {
    axe.default(React, ReactDOM, 1000);
  });
}

Best Practices Checklist

  • ✅ Use semantic HTML (<button>, <nav>, <main>)
  • ✅ Always provide text alternatives for images
  • ✅ Ensure sufficient color contrast (4.5:1 minimum)
  • ✅ Make all functionality keyboard accessible
  • ✅ Provide visible focus indicators
  • ✅ Use ARIA attributes when HTML semantics aren't enough
  • ✅ Test with real screen readers (NVDA, VoiceOver)
  • ✅ Don't rely on color alone to convey information
  • ✅ Ensure forms have proper labels and error messages
  • ✅ Implement skip links for navigation

Common Mistakes to Avoid

❌ Don't Use Divs as Buttons

// Bad
<div onClick={handleClick}>Click me</div>

// Good
<button onClick={handleClick}>Click me</button>

❌ Don't Use Placeholder as Label

// Bad
<input placeholder="Enter your email" />

// Good
<label htmlFor="email">Email</label>
<input id="email" placeholder="you@example.com" />

❌ Don't Forget Alt Text

// Bad
<img src="logo.png" />

// Good
<img src="logo.png" alt="Company logo" />

Resources for Learning More

  • WCAG Guidelines - Official accessibility standards
  • React Aria - Accessible component library from Adobe
  • Reach UI - Accessible React components
  • axe DevTools - Browser extension for testing
  • WebAIM - Articles and testing tools

Key Takeaways

  • Accessibility benefits everyone, not just users with disabilities
  • TypeScript helps catch accessibility issues at compile time
  • Use semantic HTML before reaching for ARIA attributes
  • Test with keyboard navigation and screen readers
  • Focus management is critical for modals and dynamic content
  • Provide clear labels and error messages for forms
  • Automated tools are helpful, but manual testing is essential

Building accessible React components doesn't have to be complicated. Start with semantic HTML, add ARIA attributes when needed, and always test with real users. Your future users—and your SEO rankings—will thank you!

💡 Pro tip: Install a screen reader (NVDA for Windows, VoiceOver for Mac) and navigate your components with it. This hands-on experience is invaluable!