Claude Code for Accessibility: ARIA Patterns, Screen Readers, and WCAG Compliance — Claude Skills 360 Blog
Blog / Frontend / Claude Code for Accessibility: ARIA Patterns, Screen Readers, and WCAG Compliance
Frontend

Claude Code for Accessibility: ARIA Patterns, Screen Readers, and WCAG Compliance

Published: October 20, 2026
Read time: 8 min read
By: Claude Skills 360

Accessibility is not an afterthought — retrofitting is significantly more expensive than building accessibly from the start. WCAG 2.1 AA requires sufficient color contrast, keyboard navigability, screen reader labels, and visible focus indicators. Claude Code audits existing components for accessibility issues, implements ARIA patterns correctly, and generates keyboard navigation logic for complex widgets like comboboxes, modals, and data grids.

CLAUDE.md for Accessibility-First Projects

## Accessibility Standards
- Target: WCAG 2.1 AA (minimum), WCAG 2.2 AA where possible
- Testing: axe-core in Jest (automated), NVDA + Chrome (screen reader), keyboard-only navigation
- Color contrast: minimum 4.5:1 for normal text, 3:1 for large text and UI components
- Focus: all interactive elements have visible focus indicator (outline: 2px solid #0070f3)
- ARIA: only use ARIA when native HTML semantics are insufficient
- Images: all informational images have alt text; decorative images have alt=""
- Forms: every input has a visible label (not placeholder-only)
- Motion: prefers-reduced-motion honored for all animations

Accessible Modal Dialog

// components/Modal.tsx — ARIA dialog pattern
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

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

export function Modal({ isOpen, onClose, title, children, initialFocusRef }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);
  
  useEffect(() => {
    if (isOpen) {
      // Save focus so we can restore it on close
      previousFocusRef.current = document.activeElement as HTMLElement;
      
      // Move focus to the dialog (or specified initial element)
      requestAnimationFrame(() => {
        if (initialFocusRef?.current) {
          initialFocusRef.current.focus();
        } else {
          dialogRef.current?.focus();
        }
      });
    } else {
      // Restore focus when modal closes
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);
  
  useEffect(() => {
    if (!isOpen) return;
    
    // Trap focus inside modal
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClose();
        return;
      }
      
      if (e.key !== 'Tab') return;
      
      const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
        'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
      );
      
      if (!focusable || focusable.length === 0) return;
      
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
      
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    };
    
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);
  
  if (!isOpen) return null;
  
  return createPortal(
    <>
      {/* Backdrop: dismisses on click, but is not focusable */}
      <div
        className="modal-backdrop"
        onClick={onClose}
        aria-hidden="true"
      />
      
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}  // Makes div focusable programmatically
        className="modal"
      >
        <div className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close dialog"
            className="modal-close"
          >
            ×  {/* Don't rely on visual-only × for meaning */}
          </button>
        </div>
        
        <div className="modal-body">
          {children}
        </div>
      </div>
    </>,
    document.body
  );
}

Accessible Combobox (Autocomplete)

// The combobox pattern is complex — ARIA has specific requirements
function Combobox({ id, label, options, onSelect }: ComboboxProps) {
  const [inputValue, setInputValue] = useState('');
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  
  const filteredOptions = options.filter(o =>
    o.label.toLowerCase().includes(inputValue.toLowerCase())
  );
  
  const listboxId = `${id}-listbox`;
  const activeOptionId = activeIndex >= 0 ? `${id}-option-${activeIndex}` : undefined;
  
  return (
    <div className="combobox-container">
      <label htmlFor={id}>{label}</label>
      
      <div
        role="combobox"
        aria-expanded={isOpen}
        aria-haspopup="listbox"
        aria-owns={listboxId}
      >
        <input
          id={id}
          type="text"
          value={inputValue}
          onChange={e => { setInputValue(e.target.value); setIsOpen(true); setActiveIndex(-1); }}
          onKeyDown={e => handleKeyDown(e, filteredOptions, activeIndex, setActiveIndex, onSelect, setIsOpen)}
          aria-autocomplete="list"
          aria-controls={listboxId}
          aria-activedescendant={activeOptionId}
          role="textbox"
        />
      </div>
      
      {isOpen && filteredOptions.length > 0 && (
        <ul
          id={listboxId}
          role="listbox"
          aria-label={label}
        >
          {filteredOptions.map((option, index) => (
            <li
              key={option.value}
              id={`${id}-option-${index}`}
              role="option"
              aria-selected={index === activeIndex}
              onClick={() => { onSelect(option); setIsOpen(false); setInputValue(option.label); }}
              className={index === activeIndex ? 'active' : ''}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

function handleKeyDown(
  e: React.KeyboardEvent,
  options: Option[],
  activeIndex: number,
  setActiveIndex: (i: number) => void,
  onSelect: (o: Option) => void,
  setIsOpen: (open: boolean) => void,
) {
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      setActiveIndex(Math.min(activeIndex + 1, options.length - 1));
      break;
    case 'ArrowUp':
      e.preventDefault();
      setActiveIndex(Math.max(activeIndex - 1, 0));
      break;
    case 'Enter':
      if (activeIndex >= 0) {
        e.preventDefault();
        onSelect(options[activeIndex]);
        setIsOpen(false);
      }
      break;
    case 'Escape':
      setIsOpen(false);
      setActiveIndex(-1);
      break;
  }
}

Live Regions for Dynamic Updates

// Announce dynamic content to screen readers without moving focus
function OrderStatus({ status }: { status: string }) {
  return (
    <>
      {/* aria-live="polite" announces after user finishes current action */}
      <div aria-live="polite" aria-atomic="true">
        {status && `Order status: ${status}`}
      </div>
      
      {/* aria-live="assertive" interrupts immediately (use sparingly — errors only) */}
      {status === 'FAILED' && (
        <div role="alert" aria-live="assertive">
          Payment failed. Please try again or contact support.
        </div>
      )}
    </>
  );
}

// Pattern: status announcer hook
function useAnnouncer() {
  const [message, setMessage] = useState('');
  
  const announce = useCallback((msg: string) => {
    setMessage('');  // Clear first to ensure re-announcement of same message
    requestAnimationFrame(() => setMessage(msg));
  }, []);
  
  const AnnouncerElement = () => (
    <div
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"  // visually hidden but available to screen readers
    >
      {message}
    </div>
  );
  
  return { announce, AnnouncerElement };
}

Color Contrast Audit

// scripts/audit-contrast.ts — check all color combinations in design tokens
import Color from 'colorjs.io';

const tokens = {
  textPrimary: '#1a1a1a',
  textSecondary: '#6b7280',
  textDisabled: '#9ca3af',
  backgroundWhite: '#ffffff',
  backgroundGray: '#f9fafb',
  primaryBlue: '#2563eb',
};

function checkContrast(foreground: string, background: string, context: string) {
  const fg = new Color(foreground);
  const bg = new Color(background);
  const contrast = fg.contrast(bg, 'WCAG21');
  
  const normalTextPass = contrast >= 4.5;
  const largeTextPass = contrast >= 3;
  
  console.log(`${context}: ${contrast.toFixed(2)}:1 ${normalTextPass ? '✅' : '❌'}`);
  
  if (!normalTextPass) {
    console.warn(`  FAIL: ${foreground} on ${background} — needs 4.5:1, has ${contrast.toFixed(2)}:1`);
  }
}

checkContrast(tokens.textPrimary, tokens.backgroundWhite, 'Primary text on white');
checkContrast(tokens.textSecondary, tokens.backgroundWhite, 'Secondary text on white');
checkContrast(tokens.textDisabled, tokens.backgroundWhite, 'Disabled text on white');  // Will fail — that's intentional for disabled
checkContrast(tokens.primaryBlue, tokens.backgroundWhite, 'Primary button on white');

Automated Testing with axe-core

// tests/accessibility.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Modal } from '../components/Modal';
import { Combobox } from '../components/Combobox';

expect.extend(toHaveNoViolations);

describe('Accessibility', () => {
  it('Modal has no accessibility violations when open', async () => {
    const { container } = render(
      <Modal isOpen={true} onClose={() => {}} title="Confirm Action">
        <p>Are you sure?</p>
        <button>Cancel</button>
        <button>Confirm</button>
      </Modal>
    );
    
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
  
  it('Combobox has no accessibility violations', async () => {
    const { container } = render(
      <Combobox
        id="product-search"
        label="Search products"
        options={[{ value: '1', label: 'Apple' }, { value: '2', label: 'Banana' }]}
        onSelect={() => {}}
      />
    );
    
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
  
  it('Form inputs have associated labels', async () => {
    const { container } = render(
      <form>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" />
        
        <label htmlFor="password">Password</label>
        <input id="password" type="password" />
        
        <button type="submit">Submit</button>
      </form>
    );
    
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

CSS: Focus Visible + Reduced Motion

/* Focus visible: keyboard navigation only (not mouse/touch) */
:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
  border-radius: 2px;
}

/* Remove default outline for mouse users */
:focus:not(:focus-visible) {
  outline: none;
}

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

/* Respect user's motion preference */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

For the React component architecture that hosts these accessible patterns, see the React guide. For building the test infrastructure that runs axe-core in CI, the Playwright testing guide covers end-to-end accessibility auditing. The Claude Skills 360 bundle includes accessibility skill sets covering ARIA patterns, keyboard navigation, and WCAG compliance auditing. Start with the free tier to try accessibility audit generation.

Keep Reading

Frontend

Claude Code for Chart.js Advanced: Custom Plugins and Mixed Charts

Advanced Chart.js patterns with Claude Code — chart.register() for tree-shaking, mixed chart types combining bar and line, custom plugin API with beforeDraw and afterDatasetsDraw hooks, ScriptableContext for computed colors, ChartDataLabels plugin for value labels, chartjs-plugin-zoom for pan and zoom, custom gradient fills via ctx.createLinearGradient, ChartJS annotation plugin for threshold lines, streaming data with chartjs-plugin-streaming, and react-chartjs-2 with useRef and chart instance.

6 min read Jun 27, 2027
Frontend

Claude Code for Nivo: Rich SVG and Canvas Charts

Build rich data visualizations with Nivo and Claude Code — ResponsiveLine and ResponsiveBar for adaptive charts, ResponsiveHeatMap for matrix data, ResponsiveTreeMap for hierarchal data, ResponsiveSunburst for nested proportions, ResponsiveChord for relationship diagrams, ResponsiveCalendar for activity heat maps, ResponsiveNetwork for force graphs, NivoTheme for consistent styling, tooltip customization with sliceTooltip, and motion config for spring animations.

6 min read Jun 26, 2027
Frontend

Claude Code for Victory Charts: React Native and Web Charts

Build cross-platform charts with Victory and Claude Code — VictoryChart, VictoryLine, VictoryBar, and VictoryScatter for web and React Native, VictoryPie for donut charts, VictoryArea for stacked areas, VictoryAxis for custom axes, VictoryTooltip and VictoryVoronoiContainer for hover tooltips, VictoryBrushContainer for range selection, VictoryZoomContainer for pan and zoom, VictoryLegend for series labels, custom theme with VictoryTheme, and VictoryStack for grouped bars.

6 min read Jun 25, 2027

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