How do you handle internationalization (i18n) and accessibility (a11y) in React apps?

7 minintermediatereactbest-practiceshandleinternationalizationi18naccessibility

Quick Answer

Key aspects: Internationalization (i18n) with react-i18next; Using i18n in Components; Accessibility (a11y) Implementation; Accessible Navigation; Accessible Data Tables; Custom Accessibility Hooks.

Detailed Answer

How do you handle internationalization (i18n) and accessibility (a11y) in React apps?

Answer:

1. Internationalization (i18n) with react-i18next:

// i18n configuration
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';

// Translation resources
const resources = {
  en: {
    translation: {
      welcome: 'Welcome',
      hello: 'Hello {{name}}!',
      buttons: {
        save: 'Save',
        cancel: 'Cancel',
        delete: 'Delete',
      },
      messages: {
        success: 'Operation completed successfully',
        error: 'An error occurred',
        confirm: 'Are you sure you want to delete this item?',
      },
      navigation: {
        home: 'Home',
        about: 'About',
        contact: 'Contact',
      },
    },
  },
  es: {
    translation: {
      welcome: 'Bienvenido',
      hello: '¡Hola {{name}}!',
      buttons: {
        save: 'Guardar',
        cancel: 'Cancelar',
        delete: 'Eliminar',
      },
      messages: {
        success: 'Operación completada exitosamente',
        error: 'Ocurrió un error',
        confirm: '¿Estás seguro de que quieres eliminar este elemento?',
      },
      navigation: {
        home: 'Inicio',
        about: 'Acerca de',
        contact: 'Contacto',
      },
    },
  },
};

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources,
    fallbackLng: 'en',
    debug: process.env.NODE_ENV === 'development',
    
    interpolation: {
      escapeValue: false, // React already escapes values
    },
    
    detection: {
      order: ['localStorage', 'navigator', 'htmlTag'],
      caches: ['localStorage'],
    },
  });

export default i18n;

2. Using i18n in Components:

import { useTranslation } from 'react-i18next';

// Basic usage
const WelcomePage: React.FC = () => {
  const { t, i18n } = useTranslation();

  const changeLanguage = (lng: string) => {
    i18n.changeLanguage(lng);
  };

  return (
    <div>
      <h1>{t('welcome')}</h1>
      <p>{t('hello', { name: 'John' })}</p>
      
      <div>
        <button onClick={() => changeLanguage('en')}>English</button>
        <button onClick={() => changeLanguage('es')}>Español</button>
      </div>
    </div>
  );
};

// Advanced usage with namespaces
const UserProfile: React.FC = () => {
  const { t } = useTranslation(['user', 'common']);
  
  return (
    <div>
      <h2>{t('user:profile.title')}</h2>
      <p>{t('user:profile.description')}</p>
      <button>{t('common:buttons.save')}</button>
    </div>
  );
};

// Custom hook for complex translations
const useLocalizedDate = (date: Date) => {
  const { i18n } = useTranslation();
  
  return useMemo(() => {
    return new Intl.DateTimeFormat(i18n.language, {
      year: 'numeric',
      month: 'long',
      day: 'numeric',
    }).format(date);
  }, [date, i18n.language]);
};

3. Accessibility (a11y) Implementation:

// Accessible form component
const AccessibleForm: React.FC = () => {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: '',
  });
  const [errors, setErrors] = useState<Record<string, string>>({});

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // Form validation logic
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <fieldset>
        <legend>Contact Information</legend>
        
        <div className="form-group">
          <label htmlFor="name" className="required">
            Full Name
            <span className="sr-only"> (required)</span>
          </label>
          <input
            id="name"
            type="text"
            value={formData.name}
            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
            aria-describedby={errors.name ? 'name-error' : undefined}
            aria-invalid={!!errors.name}
            required
          />
          {errors.name && (
            <div id="name-error" role="alert" className="error-message">
              {errors.name}
            </div>
          )}
        </div>

        <div className="form-group">
          <label htmlFor="email" className="required">
            Email Address
            <span className="sr-only"> (required)</span>
          </label>
          <input
            id="email"
            type="email"
            value={formData.email}
            onChange={(e) => setFormData({ ...formData, email: e.target.value })}
            aria-describedby={errors.email ? 'email-error' : undefined}
            aria-invalid={!!errors.email}
            required
          />
          {errors.email && (
            <div id="email-error" role="alert" className="error-message">
              {errors.email}
            </div>
          )}
        </div>

        <div className="form-group">
          <label htmlFor="message">Message</label>
          <textarea
            id="message"
            value={formData.message}
            onChange={(e) => setFormData({ ...formData, message: e.target.value })}
            rows={4}
            aria-describedby="message-help"
          />
          <div id="message-help" className="help-text">
            Please provide details about your inquiry.
          </div>
        </div>

        <button type="submit" className="primary-button">
          Send Message
        </button>
      </fieldset>
    </form>
  );
};

4. Accessible Navigation:

const AccessibleNavigation: React.FC = () => {
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const menuRef = useRef<HTMLUListElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);

  const toggleMenu = () => {
    setIsMenuOpen(!isMenuOpen);
  };

  const closeMenu = () => {
    setIsMenuOpen(false);
    buttonRef.current?.focus();
  };

  // Handle keyboard navigation
  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Escape') {
      closeMenu();
    }
  };

  // Handle focus management
  useEffect(() => {
    if (isMenuOpen && menuRef.current) {
      const firstMenuItem = menuRef.current.querySelector('a');
      firstMenuItem?.focus();
    }
  }, [isMenuOpen]);

  return (
    <nav role="navigation" aria-label="Main navigation">
      <button
        ref={buttonRef}
        onClick={toggleMenu}
        aria-expanded={isMenuOpen}
        aria-controls="main-menu"
        aria-haspopup="true"
        className="menu-toggle"
      >
        <span className="sr-only">
          {isMenuOpen ? 'Close' : 'Open'} main menu
        </span>
        <span aria-hidden="true"></span>
      </button>

      <ul
        ref={menuRef}
        id="main-menu"
        className={`main-menu ${isMenuOpen ? 'open' : ''}`}
        onKeyDown={handleKeyDown}
        role="menubar"
      >
        <li role="none">
          <a href="/" role="menuitem" tabIndex={isMenuOpen ? 0 : -1}>
            Home
          </a>
        </li>
        <li role="none">
          <a href="/about" role="menuitem" tabIndex={isMenuOpen ? 0 : -1}>
            About
          </a>
        </li>
        <li role="none">
          <a href="/contact" role="menuitem" tabIndex={isMenuOpen ? 0 : -1}>
            Contact
          </a>
        </li>
      </ul>
    </nav>
  );
};

5. Accessible Data Tables:

interface TableData {
  id: string;
  name: string;
  email: string;
  role: string;
  status: 'active' | 'inactive';
}

const AccessibleTable: React.FC<{ data: TableData[] }> = ({ data }) => {
  const [sortConfig, setSortConfig] = useState<{
    key: keyof TableData;
    direction: 'asc' | 'desc';
  } | null>(null);

  const handleSort = (key: keyof TableData) => {
    setSortConfig(prev => ({
      key,
      direction: prev?.key === key && prev.direction === 'asc' ? 'desc' : 'asc',
    }));
  };

  const sortedData = useMemo(() => {
    if (!sortConfig) return data;
    
    return [...data].sort((a, b) => {
      const aVal = a[sortConfig.key];
      const bVal = b[sortConfig.key];
      
      if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
      if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
      return 0;
    });
  }, [data, sortConfig]);

  return (
    <div className="table-container">
      <table role="table" aria-label="User data table">
        <caption className="sr-only">
          User data table with sortable columns
        </caption>
        <thead>
          <tr role="row">
            <th
              role="columnheader"
              tabIndex={0}
              onClick={() => handleSort('name')}
              onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
              aria-sort={
                sortConfig?.key === 'name'
                  ? sortConfig.direction === 'asc' ? 'ascending' : 'descending'
                  : 'none'
              }
            >
              Name
              <span className="sr-only">
                {sortConfig?.key === 'name'
                  ? `Sorted ${sortConfig.direction === 'asc' ? 'ascending' : 'descending'}`
                  : 'Click to sort'}
              </span>
            </th>
            <th
              role="columnheader"
              tabIndex={0}
              onClick={() => handleSort('email')}
              onKeyDown={(e) => e.key === 'Enter' && handleSort('email')}
              aria-sort={
                sortConfig?.key === 'email'
                  ? sortConfig.direction === 'asc' ? 'ascending' : 'descending'
                  : 'none'
              }
            >
              Email
            </th>
            <th
              role="columnheader"
              tabIndex={0}
              onClick={() => handleSort('role')}
              onKeyDown={(e) => e.key === 'Enter' && handleSort('role')}
              aria-sort={
                sortConfig?.key === 'role'
                  ? sortConfig.direction === 'asc' ? 'ascending' : 'descending'
                  : 'none'
              }
            >
              Role
            </th>
            <th
              role="columnheader"
              tabIndex={0}
              onClick={() => handleSort('status')}
              onKeyDown={(e) => e.key === 'Enter' && handleSort('status')}
              aria-sort={
                sortConfig?.key === 'status'
                  ? sortConfig.direction === 'asc' ? 'ascending' : 'descending'
                  : 'none'
              }
            >
              Status
            </th>
          </tr>
        </thead>
        <tbody>
          {sortedData.map((row) => (
            <tr key={row.id} role="row">
              <td role="cell">{row.name}</td>
              <td role="cell">{row.email}</td>
              <td role="cell">{row.role}</td>
              <td role="cell">
                <span
                  className={`status-badge ${row.status}`}
                  aria-label={`Status: ${row.status}`}
                >
                  {row.status}
                </span>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

6. Custom Accessibility Hooks:

// Focus management hook
const useFocusManagement = () => {
  const focusableElements = useRef<HTMLElement[]>([]);
  
  const trapFocus = (container: HTMLElement) => {
    const focusable = container.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    ) as NodeListOf<HTMLElement>;
    
    focusableElements.current = Array.from(focusable);
    
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Tab') {
        const firstElement = focusableElements.current[0];
        const lastElement = focusableElements.current[focusableElements.current.length - 1];
        
        if (e.shiftKey) {
          if (document.activeElement === firstElement) {
            lastElement.focus();
            e.preventDefault();
          }
        } else {
          if (document.activeElement === lastElement) {
            firstElement.focus();
            e.preventDefault();
          }
        }
      }
    };
    
    container.addEventListener('keydown', handleKeyDown);
    
    return () => {
      container.removeEventListener('keydown', handleKeyDown);
    };
  };
  
  return { trapFocus };
};

// Screen reader announcements
const useScreenReaderAnnouncement = () => {
  const announce = useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
    const announcement = document.createElement('div');
    announcement.setAttribute('aria-live', priority);
    announcement.setAttribute('aria-atomic', 'true');
    announcement.className = 'sr-only';
    announcement.textContent = message;
    
    document.body.appendChild(announcement);
    
    setTimeout(() => {
      document.body.removeChild(announcement);
    }, 1000);
  }, []);
  
  return { announce };
};

7. Best Practices:

Internationalization:

  • Use semantic keys for translations
  • Support pluralization and interpolation
  • Handle RTL languages properly
  • Test with different text lengths
  • Use proper date/number formatting
  • Consider cultural differences in UI patterns

Accessibility:

  • Use semantic HTML elements
  • Provide proper ARIA labels and roles
  • Ensure keyboard navigation works
  • Maintain proper focus management
  • Test with screen readers
  • Use sufficient color contrast
  • Provide alternative text for images
  • Make interactive elements large enough
  • Use proper heading hierarchy
  • Test with real users with disabilities