agentleFS
medy-gribkovmedy-gribkov/arcana/skills/accessibility-wcag/SKILL.md

accessibility-wcag

Skill1 starsChanged 8 months agoApache-2.0View on GitHub

Audit and fix web accessibility issues against WCAG 2.1/2.2 AA: semantic HTML, ARIA patterns, keyboard navigation, focus management, color contrast, and screen reader support. Use when reviewing or writing React/HTML/CSS for accessibility, fixing axe or Lighthouse findings, adding ARIA labels, building accessible modals, forms, menus, or tabs, or when the user mentions a11y, WCAG, screen readers, or keyboard access.

We split this skill into the rules it gives an agent

2 rules. About 0 are common, 2 are distinctive, and 0 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 0

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Verify color contrast: Use contrast checker for all text/UI elements

    line 451
  2. Distinctive:

    Validate ARIA: Ensure roles/states match component behavior

    line 452

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Accessibility & WCAG Compliance

Audit web applications for WCAG 2.1/2.2 compliance, fix accessibility barriers, implement proper semantic HTML and ARIA patterns.

BAD: Common Accessibility Violations

// BAD: Div button without keyboard support
<div className="btn" onClick={handleClick}>
  Submit
</div>

// BAD: Color-only error indication
<input className="border-red-500" />
<span style={{color: 'red'}}>Error</span>

// BAD: Image without alt text
<img src="/chart.png" />

// BAD: Aria-label overuse
<button aria-label="Click me">Click me</button>

// BAD: Auto-focus abuse
<input autoFocus />
<Modal open={isOpen}>
  <input autoFocus /> {/* Steals focus */}
</Modal>

// BAD: No heading hierarchy
<h1>Page Title</h1>
<h4>Section</h4> {/* Skips h2, h3 */}

// BAD: Form without labels
<input type="text" placeholder="Email" />
<select>
  <option>Choose...</option>
</select>

// BAD: Custom dropdown without keyboard support
<div onClick={toggle}>
  {options.map(opt => <div onClick={() => select(opt)}>{opt}</div>)}
</div>

GOOD: Accessible Implementations

// GOOD: Semantic button with proper keyboard support
<button type="button" onClick={handleClick}>
  Submit
</button>

// GOOD: Multi-sensory error indication
<input
  className="border-red-500"
  aria-invalid="true"
  aria-describedby="email-error"
/>
<span id="email-error" className="text-red-600" role="alert">
  <span className="sr-only">Error:</span>
  Invalid email format
</span>

// GOOD: Descriptive alt text (or empty for decorative)
<img src="/sales-chart.png" alt="Sales increased 40% in Q4" />
<img src="/decorative-line.svg" alt="" role="presentation" />

// GOOD: Redundant aria-label removed
<button>Click me</button>

// GOOD: Managed focus (move focus in, restore on close).
// For a real focus trap prefer native <dialog> with showModal(), or set `inert` on the page behind.
function Modal({ open, onClose, children }) {
  const modalRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;

    const previousFocus = document.activeElement as HTMLElement;
    modalRef.current?.focus();

    return () => previousFocus?.focus();
  }, [open]);

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      tabIndex={-1}
      onKeyDown={(e) => {
        if (e.key === 'Escape') onClose();
      }}
    >
      {children}
    </div>
  );
}

// GOOD: Proper heading hierarchy
<h1>Page Title</h1>
<h2>Main Section</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>

// GOOD: Labeled form controls
<label htmlFor="email">Email address</label>
<input
  id="email"
  type="email"
  aria-required="true"
  aria-describedby="email-hint"
/>
<span id="email-hint" className="text-gray-600">
  We'll never share your email
</span>

<label htmlFor="country">Country</label>
<select id="country" aria-required="true">
  <option value="">Select a country</option>
  <option value="us">United States</option>
</select>

// GOOD: Accessible custom dropdown
function Dropdown({ options, value, onChange, label }) {
  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const listRef = useRef<HTMLUListElement>(null);

  const handleKeyDown = (e: KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(prev => Math.min(prev + 1, options.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(prev => Math.max(prev - 1, 0));
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        onChange(options[activeIndex]);
        setOpen(false);
        buttonRef.current?.focus();
        break;
      case 'Escape':
        setOpen(false);
        buttonRef.current?.focus();
        break;
    }
  };

  return (
    <div>
      <button
        ref={buttonRef}
        type="button"
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-labelledby="dropdown-label"
        onClick={() => setOpen(!open)}
      >
        <span id="dropdown-label">{label}</span>: {value}
      </button>

      {open && (
        <ul
          ref={listRef}
          role="listbox"
          aria-labelledby="dropdown-label"
          onKeyDown={handleKeyDown}
          tabIndex={-1}
        >
          {options.map((opt, i) => (
            <li
              key={opt}
              role="option"
              aria-selected={i === activeIndex}
              onClick={() => {
                onChange(opt);
                setOpen(false);
                buttonRef.current?.focus();
              }}
            >
              {opt}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Keyboard Navigation Patterns

// Roving tabindex for toolbar
function Toolbar({ items }) {
  const [focusedIndex, setFocusedIndex] = useState(0);

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

    if (e.key === 'ArrowRight') {
      newIndex = (index + 1) % items.length;
    } else if (e.key === 'ArrowLeft') {
      newIndex = (index - 1 + items.length) % items.length;
    } else if (e.key === 'Home') {
      newIndex = 0;
    } else if (e.key === 'End') {
      newIndex = items.length - 1;
    } else {
      return;
    }

    e.preventDefault();
    setFocusedIndex(newIndex);
  };

  return (
    <div role="toolbar" aria-label="Formatting tools">
      {items.map((item, i) => (
        <button
          key={item.id}
          type="button"
          tabIndex={i === focusedIndex ? 0 : -1}
          onKeyDown={(e) => handleKeyDown(e, i)}
          onFocus={() => setFocusedIndex(i)}
        >
          {item.label}
        </button>
      ))}
    </div>
  );
}

// Skip navigation link
function Layout({ children }) {
  return (
    <>
      <a
        href="#main-content"
        className="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:p-4 focus:bg-blue-600 focus:text-white"
      >
        Skip to main content
      </a>

      <nav aria-label="Main navigation">
        {/* Nav items */}
      </nav>

      <main id="main-content" tabIndex={-1}>
        {children}
      </main>
    </>
  );
}

// Focus-visible styling (CSS)
/* Show focus only for keyboard users */
button:focus {
  outline: none;
}

button:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

Live Regions and Dynamic Content

// Status announcements
function Toast({ message, type }) {
  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className="toast"
    >
      {message}
    </div>
  );
}

// Urgent alerts
function ErrorAlert({ message }) {
  return (
    <div
      role="alert"
      aria-live="assertive"
      className="bg-red-100 border border-red-600 p-4"
    >
      <strong>Error:</strong> {message}
    </div>
  );
}

// Loading state announcement
function DataTable({ loading, data }) {
  return (
    <div>
      {loading && (
        <div role="status" aria-live="polite">
          <span className="sr-only">Loading data...</span>
          <div className="spinner" aria-hidden="true" />
        </div>
      )}

      <table>
        <caption className="sr-only">Sales data for 2025</caption>
        <thead>
          <tr>
            <th scope="col">Month</th>
            <th scope="col">Revenue</th>
          </tr>
        </thead>
        <tbody>
          {data.map(row => (
            <tr key={row.id}>
              <th scope="row">{row.month}</th>
              <td>{row.revenue}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Color Contrast and Motion

/* GOOD: WCAG AA contrast ratios */
.text-normal {
  color: #1a1a1a; /* about 17:1 on white, passes 4.5:1 */
  background: #ffffff;
}

.text-large {
  font-size: 18px;
  font-weight: 700;
  color: #4a4a4a; /* about 9:1 on white, passes 3:1 for large text */
}

.button-primary {
  background: #0066cc; /* about 5.6:1 with white text, passes 4.5:1 */
  color: #ffffff;
}

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

/* GOOD: Graceful animation degradation */
@media (prefers-reduced-motion: no-preference) {
  .fade-in {
    animation: fadeIn 0.3s ease-in;
  }
}

@media (prefers-reduced-motion: reduce) {
  .fade-in {
    opacity: 1; /* Skip animation, show immediately */
  }
}

Automated Testing

// axe-core integration (Jest + React Testing Library)
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';

expect.extend(toHaveNoViolations);

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

// Playwright accessibility audit
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('Homepage should not have accessibility violations', async ({ page }) => {
  await page.goto('http://localhost:3000');

  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
    .analyze();

  expect(results.violations).toEqual([]);
});

// Cypress accessibility testing
describe('Accessibility', () => {
  beforeEach(() => {
    cy.visit('/');
    cy.injectAxe();
  });

  it('Has no detectable accessibility violations', () => {
    cy.checkA11y();
  });

  it('Has no violations on specific component', () => {
    cy.checkA11y('.navigation-menu');
  });

  it('Respects wcag2aa standard', () => {
    cy.checkA11y(null, {
      runOnly: {
        type: 'tag',
        values: ['wcag2aa']
      }
    });
  });
});

Workflow

When auditing accessibility:

  1. Run automated tools first: axe DevTools, Lighthouse, WAVE
  2. Check keyboard navigation: Tab through entire page, verify focus order
  3. Test with screen reader: NVDA (Windows), VoiceOver (Mac), JAWS
  4. Verify color contrast: Use contrast checker for all text/UI elements
  5. Validate ARIA: Ensure roles/states match component behavior
  6. Test dynamic content: Live regions announce properly
  7. Check form accessibility: Labels, errors, validation messages
  8. Verify responsive behavior: Touch targets at least 24x24 CSS px (WCAG 2.2 AA, 2.5.8); 44x44 is the AAA target (2.5.5) and a good default on mobile

Common WCAG 2.1 Level AA failures:

  • Missing alt text (1.1.1)
  • Insufficient contrast (1.4.3)
  • No keyboard access (2.1.1)
  • Missing form labels (3.3.2)
  • Invalid or missing ARIA name, role, value (4.1.2). Criterion 4.1.1 Parsing was removed in WCAG 2.2

New in WCAG 2.2 AA: focus not obscured by sticky headers (2.4.11), target size minimum (2.5.8), consistent help (3.2.6), redundant entry (3.3.7), accessible authentication without cognitive tests (3.3.8), dragging alternatives (2.5.7).

Fix priority: Critical (blocks screen readers) > High (keyboard issues) > Medium (contrast) > Low (enhancements).

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.