Claude
Skills
Sign in
Back

accessibility-a11y

Included with Lifetime
$97 forever

WCAG 2.2 compliance, ARIA patterns, keyboard navigation, screen readers, automated testing

General

What this skill does


# Accessibility (a11y)

## Overview

This skill covers building accessible web applications that work for everyone, including people using screen readers, keyboard-only navigation, switch devices, and other assistive technologies. It addresses WCAG 2.2 compliance at AA and AAA levels, correct ARIA usage, focus management, color contrast, reduced motion support, and automated testing integration.

Use this skill when building new UI components, reviewing existing interfaces for accessibility compliance, fixing a11y audit findings, or integrating automated accessibility testing into CI/CD pipelines.

---

## Core Principles

1. **Semantic HTML first** - Native HTML elements (`<button>`, `<nav>`, `<dialog>`) provide accessibility for free. ARIA is a repair tool for when semantics are insufficient, not a replacement for proper HTML.
2. **Keyboard is the baseline** - If it doesn't work with a keyboard alone, it doesn't work. Every interactive element must be focusable, operable, and have visible focus indicators.
3. **Test with real assistive technology** - Automated tools catch ~30% of accessibility issues. The rest require manual testing with screen readers (VoiceOver, NVDA) and keyboard-only navigation.
4. **Progressive enhancement** - Build the accessible version first, then layer on visual enhancements. Never hide content from assistive technology that sighted users can see.
5. **No information by color alone** - Color can reinforce meaning but never be the sole indicator. Use icons, text labels, and patterns alongside color.

---

## Key Patterns

### Pattern 1: Accessible Modal Dialog

**When to use:** Any overlay that requires user interaction before returning to the main content.

**Implementation:**

```tsx
import { useRef, useEffect, useCallback } from "react";

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

export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (isOpen) {
      // Store the element that had focus before opening
      previousFocusRef.current = document.activeElement as HTMLElement;
      dialog.showModal();
    } else {
      dialog.close();
      // Restore focus to the triggering element
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  // Handle Escape key (native dialog handles this, but we need cleanup)
  const handleClose = useCallback(() => {
    onClose();
  }, [onClose]);

  // Handle backdrop click
  const handleBackdropClick = useCallback(
    (e: React.MouseEvent<HTMLDialogElement>) => {
      if (e.target === dialogRef.current) {
        onClose();
      }
    },
    [onClose]
  );

  if (!isOpen) return null;

  return (
    <dialog
      ref={dialogRef}
      onClose={handleClose}
      onClick={handleBackdropClick}
      aria-labelledby="dialog-title"
      aria-describedby="dialog-description"
      className="dialog"
    >
      <div className="dialog-content" role="document">
        <header className="dialog-header">
          <h2 id="dialog-title">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close dialog"
            className="dialog-close"
          >
            <span aria-hidden="true">&times;</span>
          </button>
        </header>
        <div id="dialog-description">{children}</div>
      </div>
    </dialog>
  );
}
```

```css
/* Focus trap is handled natively by <dialog> showModal() */
dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

dialog .dialog-close:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}
```

**Why:** The native `<dialog>` element with `showModal()` provides focus trapping, Escape key handling, and proper `role="dialog"` semantics automatically. Custom modal implementations almost always have focus trap bugs. Using the native element gives you correct behavior for free.

---

### Pattern 2: Accessible Form with Error Handling

**When to use:** Any form that collects user input and validates it.

**Implementation:**

```tsx
interface FormFieldProps {
  id: string;
  label: string;
  type?: string;
  required?: boolean;
  error?: string;
  description?: string;
  value: string;
  onChange: (value: string) => void;
}

function FormField({
  id,
  label,
  type = "text",
  required = false,
  error,
  description,
  value,
  onChange,
}: FormFieldProps) {
  const descriptionId = description ? `${id}-description` : undefined;
  const errorId = error ? `${id}-error` : undefined;

  // Build aria-describedby from available descriptions
  const describedBy = [descriptionId, errorId].filter(Boolean).join(" ") || undefined;

  return (
    <div className="form-field">
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only"> (required)</span>}
      </label>

      {description && (
        <p id={descriptionId} className="field-description">
          {description}
        </p>
      )}

      <input
        id={id}
        type={type}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        required={required}
        aria-invalid={error ? "true" : undefined}
        aria-describedby={describedBy}
        aria-required={required}
      />

      {error && (
        <p id={errorId} className="field-error" role="alert">
          <span aria-hidden="true">!</span> {error}
        </p>
      )}
    </div>
  );
}

// Form-level error summary for screen readers
function ErrorSummary({ errors }: { errors: Record<string, string> }) {
  const errorEntries = Object.entries(errors);
  if (errorEntries.length === 0) return null;

  return (
    <div role="alert" aria-labelledby="error-summary-title" className="error-summary">
      <h3 id="error-summary-title">
        {errorEntries.length} error{errorEntries.length > 1 ? "s" : ""} found
      </h3>
      <ul>
        {errorEntries.map(([field, message]) => (
          <li key={field}>
            <a href={`#${field}`}>{message}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}
```

```css
/* Screen-reader only class */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

.field-error {
  color: var(--color-error);
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

/* Never rely on color alone for errors - include icon */
.field-error::before {
  content: "";
  /* Error icon via background-image */
}

input[aria-invalid="true"] {
  border-color: var(--color-error);
  /* Also use a thicker border or icon, not just color */
  border-width: 2px;
}
```

**Why:** Forms are the most common source of accessibility failures. This pattern ensures every field has a programmatic label, errors are announced via `role="alert"`, error messages are linked to inputs via `aria-describedby`, and the error summary lets keyboard users jump directly to problematic fields.

---

### Pattern 3: Keyboard Navigation for Custom Widgets

**When to use:** Building custom interactive components (tabs, menus, listboxes, comboboxes) that don't map to native HTML elements.

**Implementation:**

```tsx
// Accessible tabs following WAI-ARIA Authoring Practices
interface Tab {
  id: string;
  label: string;
  content: React.ReactNode;
}

function Tabs({ tabs }: { tabs: Tab[] }) {
  const [activeIndex, setActiveIndex] = useState(0);

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    let newIndex = index;

    switch (e.key) {
      case "ArrowRight":
        newIndex = (index + 1) % tabs.length;
        break;
      case "ArrowLeft":
        newIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case "Home"
Files: 1
Size: 16.6 KB
Complexity: 19/100
Category: General

Related in General