Claude Code for Accessibility: WCAG Compliance, ARIA, and Automated Testing — Claude Skills 360 Blog
Blog / Development / Claude Code for Accessibility: WCAG Compliance, ARIA, and Automated Testing
Development

Claude Code for Accessibility: WCAG Compliance, ARIA, and Automated Testing

Published: May 29, 2026
Read time: 8 min read
By: Claude Skills 360

Accessibility is often treated as an afterthought — something audited right before launch, resulting in a long list of violations with no time to fix them. Claude Code makes accessibility part of the development workflow: auditing components as you build them, generating correct ARIA patterns, and writing automated tests that catch regressions before they reach production.

This guide covers web accessibility with Claude Code: WCAG compliance, semantic HTML, ARIA patterns, keyboard navigation, and automated testing with axe-core.

Setting Up for Accessibility Work

CLAUDE.md for accessible applications

## Accessibility Standards
- Target: WCAG 2.1 AA compliance minimum
- Automated testing: axe-core integrated in Vitest + Playwright
- Manual testing: keyboard navigation + screen reader (NVDA/JAWS on Windows, VoiceOver on Mac)
- Color contrast: minimum 4.5:1 for normal text, 3:1 for large text (18px+ or 14px+ bold)
- Focus management: visible focus indicator required on all interactive elements
- No ARIA without need — prefer semantic HTML; ARIA only when native semantics aren't sufficient
- Test with: axe-core (automated), keyboard-only navigation (manual), screen reader (at least quarterly)

## Accessibility anti-patterns we've been burned by
- Using div/span for buttons — breaks keyboard access and screen reader announcements
- Missing alt text on product images — critical for e-commerce
- Color alone to convey meaning (e.g., red = error) — fails for color blindness
- Modals without focus trap — screen reader users get lost
- Auto-playing media — violates WCAG 1.4.2

Auditing Existing Components

Audit this component for accessibility issues.
Check WCAG 2.1 AA compliance, keyboard navigation, and screen reader support.

Paste in a component and Claude Code returns a prioritized list:

Common issues Claude detects:

  • Missing or incorrect ARIA labels on interactive elements
  • Non-semantic HTML for buttons and links (<div onClick> instead of <button>)
  • Images missing alt text or using filename as alt text
  • Form inputs missing associated labels
  • Color contrast failures
  • Missing focus indicators
  • Modals without focus trap
  • Dynamic content updates not announced to screen readers

Example: Fixing a Non-Accessible Button

// Before — common anti-pattern
function DeleteButton({ onDelete }: { onDelete: () => void }) {
  return (
    <div
      className="delete-btn"
      style={{ cursor: 'pointer' }}
      onClick={onDelete}
    >
      <svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12z"/></svg>
    </div>
  );
}

// After — accessible
function DeleteButton({ onDelete, itemName }: { onDelete: () => void; itemName: string }) {
  return (
    <button
      type="button"
      onClick={onDelete}
      aria-label={`Delete ${itemName}`}
      className="delete-btn"
    >
      {/* Icon is decorative — hidden from screen readers */}
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12z"/>
      </svg>
      <span className="sr-only">Delete</span>
    </button>
  );
}

Key fixes: native <button> gives keyboard access and correct role for free. aria-label with context (Delete ${itemName}) tells screen reader users exactly what gets deleted. aria-hidden="true" on the SVG prevents it from being read as “graphic”.

Form Accessibility

Make this checkout form fully accessible.
It needs to work with screen readers and keyboard-only navigation.
function CheckoutForm() {
  const { register, formState: { errors }, handleSubmit } = useForm();

  return (
    <form
      aria-labelledby="checkout-heading"
      noValidate // Let our custom validation handle it
      onSubmit={handleSubmit(onSubmit)}
    >
      <h2 id="checkout-heading">Shipping Information</h2>

      {/* Group related fields with fieldset */}
      <fieldset>
        <legend>Name</legend>

        <div className="field-group">
          <label htmlFor="firstName">
            First name
            <span aria-hidden="true"> *</span>
          </label>
          <input
            id="firstName"
            type="text"
            autoComplete="given-name"
            aria-required="true"
            aria-invalid={!!errors.firstName}
            aria-describedby={errors.firstName ? 'firstName-error' : undefined}
            {...register('firstName', { required: 'First name is required' })}
          />
          {errors.firstName && (
            <span
              id="firstName-error"
              role="alert"
              className="error-message"
            >
              {errors.firstName.message}
            </span>
          )}
        </div>
      </fieldset>

      {/* Required field legend outside the fieldset */}
      <p className="required-note">
        <span aria-hidden="true">*</span> indicates a required field
      </p>

      {/* Submit button with loading state */}
      <button
        type="submit"
        aria-busy={isSubmitting}
        disabled={isSubmitting}
      >
        {isSubmitting ? (
          <>
            <span aria-hidden="true">Processing...</span>
            <span className="sr-only">Processing your order, please wait</span>
          </>
        ) : (
          'Place order'
        )}
      </button>
    </form>
  );
}

Critical patterns: aria-describedby connects the error message to the input so screen readers announce both the field and the error. role="alert" on error messages makes them announced immediately when they appear. aria-busy on the submit button tells screen readers the form is processing.

This modal doesn't trap focus. When you Tab through it,
focus escapes to the background page. Fix it.
import { useEffect, useRef } from 'react';
import FocusTrap from 'focus-trap-react'; // Or implement manually

function AccessibleModal({
  isOpen,
  onClose,
  title,
  children,
}: {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // Save previous focus to restore when modal closes
      previousFocusRef.current = document.activeElement as HTMLElement;
    } else {
      // Restore focus to trigger element
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    // Block background content from screen readers
    <div aria-modal="true" aria-live="polite">
      {/* Overlay — click to close */}
      <div
        className="modal-overlay"
        onClick={onClose}
        aria-hidden="true" // Decorative
      />

      <FocusTrap>
        <div
          role="dialog"
          aria-labelledby="modal-title"
          aria-describedby="modal-description"
          ref={dialogRef}
          className="modal"
        >
          <h2 id="modal-title">{title}</h2>

          <div id="modal-description">
            {children}
          </div>

          <button
            type="button"
            onClick={onClose}
            aria-label="Close dialog"
            className="modal-close"
          >
            <svg aria-hidden="true" focusable="false">...</svg>
          </button>
        </div>
      </FocusTrap>
    </div>
  );
}

role="dialog" with aria-labelledby pointing to the heading title is the correct ARIA pattern. The focus trap ensures keyboard users can’t escape to background content. Restoring focus to the trigger element on close prevents keyboard users from losing their place.

Automated Testing with axe-core

Add accessibility testing to our Vitest test suite.
Every component should fail tests if it has WCAG violations.
// tests/setup.ts
import { configureAxe, toHaveNoViolations } from 'jest-axe';                           
import { expect } from 'vitest';

const axe = configureAxe({
  rules: {
    // Enable all WCAG 2.1 AA rules
    'color-contrast': { enabled: true },
    'keyboard-navigation': { enabled: true },
  },
});

// Extend vitest's expect
expect.extend(toHaveNoViolations);
// tests/components/Button.test.tsx
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import { Button } from '../src/components/Button';

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

  test('icon-only button has accessible label', async () => {
    const { container } = render(
      <Button
        aria-label="Delete item"
        icon={<TrashIcon />}
      />
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  test('disabled button is announced correctly', async () => {
    const { container } = render(
      <Button disabled aria-describedby="reason">
        Submit
      </Button>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

E2E Accessibility Testing with Playwright

// tests/e2e/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Page accessibility', () => {
  test('homepage has no critical violations', async ({ page }) => {
    await page.goto('/');
    await page.waitForLoadState('networkidle');

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa'])
      .exclude('[aria-hidden="true"]') // Skip intentionally hidden content
      .analyze();

    // Separate critical from minor violations
    const criticalViolations = results.violations.filter(
      v => v.impact === 'critical' || v.impact === 'serious'
    );

    expect(criticalViolations).toEqual([]); // Fail on critical only
  });

  test('checkout flow is keyboard navigable', async ({ page }) => {
    await page.goto('/checkout');

    // Tab through all interactive elements
    const focusableElements: string[] = [];
    let iterations = 0;

    while (iterations < 50) {
      await page.keyboard.press('Tab');
      const focused = await page.evaluate(() => {
        const el = document.activeElement;
        return el ? `${el.tagName}[${el.getAttribute('aria-label') ?? el.textContent?.trim()}]` : null;
      });
      if (!focused || focusableElements.includes(focused)) break;
      focusableElements.push(focused);
      iterations++;
    }

    // All form fields should be reachable by keyboard
    expect(focusableElements.some(el => el.includes('Email'))).toBe(true);
    expect(focusableElements.some(el => el.includes('Place order'))).toBe(true);
  });
});

Color Contrast and Visual Design

Generate a color palette for our design system that meets WCAG AA contrast ratios.
Primary brand color is #2563EB (blue).

Claude Code calculates contrast ratios and suggests compliant text colors:

// utility to check contrast in your design tokens
function getContrastRatio(foreground: string, background: string): number {
  const l1 = getRelativeLuminance(foreground);
  const l2 = getRelativeLuminance(background);
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

// Design tokens with built-in contrast info
const colors = {
  // Primary #2563EB on white = 4.6:1 ✓ (AA for normal text)
  'text-on-primary': '#FFFFFF',
  
  // For error states — red must meet contrast too
  // #DC2626 on #FEF2F2 = 4.8:1 ✓
  'error-text': '#DC2626',
  'error-bg': '#FEF2F2',
  
  // Disabled states — often fail contrast (using opacity reduces contrast)
  // Don't use opacity for disabled text — use a specific gray
  'text-disabled': '#6B7280', // 4.61:1 on white ✓
};

Accessibility is a code quality issue, not just a compliance checkbox. The code review guide covers adding accessibility checks to your PR review process. The testing and debugging guide covers the broader testing strategy. The Claude Skills 360 bundle includes an accessibility audit skill that applies WCAG 2.1 AA criteria systematically. Start with the free tier to run an accessibility audit on your project.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free