accessibility-auditor

Accessibility Auditor

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "accessibility-auditor" with this command: npx skills add monkey1sai/openai-cli/monkey1sai-openai-cli-accessibility-auditor

Accessibility Auditor

Build inclusive web experiences with WCAG 2.1 compliance and comprehensive a11y patterns.

Core Workflow

  • Audit existing code: Identify accessibility issues

  • Check WCAG compliance: Verify against success criteria

  • Fix semantic HTML: Use proper elements and landmarks

  • Add ARIA attributes: Enhance assistive technology support

  • Implement keyboard nav: Ensure full keyboard accessibility

  • Test with tools: Automated and manual testing

  • Verify with screen readers: Real-world testing

WCAG 2.1 Quick Reference

Compliance Levels

Level Description Requirement

A Minimum accessibility Must have

AA Standard compliance Industry standard

AAA Enhanced accessibility Nice to have

Four Principles (POUR)

  • Perceivable: Content must be presentable to all senses

  • Operable: Interface must be navigable by all users

  • Understandable: Content must be clear and predictable

  • Robust: Content must work with assistive technologies

Semantic HTML

Use Proper Elements

<!-- Bad: Divs for everything --> <div class="header"> <div class="nav"> <div onclick="navigate()">Home</div> </div> </div>

<!-- Good: Semantic elements --> <header> <nav aria-label="Main navigation"> <a href="/">Home</a> </nav> </header>

Document Landmarks

<body> <header> <nav aria-label="Main">...</nav> </header>

<main id="main-content"> <article> <h1>Page Title</h1> <section aria-labelledby="section-heading"> <h2 id="section-heading">Section</h2> </section> </article> <aside aria-label="Related content">...</aside> </main>

<footer>...</footer> </body>

Heading Hierarchy

<!-- Correct heading order --> <h1>Page Title</h1> <h2>Section</h2> <h3>Subsection</h3> <h3>Subsection</h3> <h2>Section</h2> <h3>Subsection</h3>

<!-- Never skip levels --> <!-- Bad: h1 → h3 (skipped h2) -->

ARIA Patterns

Buttons

// Interactive element that looks like a button <button type="button" onClick={handleClick}> Click me </button>

// If you must use a div (avoid if possible) <div role="button" tabIndex={0} onClick={handleClick} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }}

Click me </div>

Modals / Dialogs

// components/Modal.tsx import { useEffect, useRef } from 'react';

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

export function Modal({ isOpen, onClose, title, children }: ModalProps) { const modalRef = useRef<HTMLDivElement>(null); const previousActiveElement = useRef<Element | null>(null);

useEffect(() => { if (isOpen) { // Store current focus previousActiveElement.current = document.activeElement; // Focus modal modalRef.current?.focus(); // Prevent body scroll document.body.style.overflow = 'hidden'; } else { // Restore focus (previousActiveElement.current as HTMLElement)?.focus(); document.body.style.overflow = ''; }

return () => {
  document.body.style.overflow = '';
};

}, [isOpen]);

// Handle escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape' && isOpen) { onClose(); } };

document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);

}, [isOpen, onClose]);

if (!isOpen) return null;

return ( <div className="fixed inset-0 z-50 flex items-center justify-center" role="presentation" > {/* Backdrop */} <div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />

  {/* Modal */}
  &#x3C;div
    ref={modalRef}
    role="dialog"
    aria-modal="true"
    aria-labelledby="modal-title"
    tabIndex={-1}
    className="relative z-10 bg-white rounded-lg p-6 max-w-md w-full"
  >
    &#x3C;h2 id="modal-title" className="text-xl font-bold">
      {title}
    &#x3C;/h2>

    &#x3C;div className="mt-4">{children}&#x3C;/div>

    &#x3C;button
      onClick={onClose}
      className="absolute top-4 right-4"
      aria-label="Close modal"
    >
      ×
    &#x3C;/button>
  &#x3C;/div>
&#x3C;/div>

); }

Tabs

// components/Tabs.tsx import { useState, useRef, KeyboardEvent } from 'react';

interface Tab { id: string; label: string; content: React.ReactNode; }

export function Tabs({ tabs }: { tabs: Tab[] }) { const [activeTab, setActiveTab] = useState(tabs[0].id); const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

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

switch (e.key) {
  case 'ArrowLeft':
    newIndex = index === 0 ? tabs.length - 1 : index - 1;
    break;
  case 'ArrowRight':
    newIndex = index === tabs.length - 1 ? 0 : index + 1;
    break;
  case 'Home':
    newIndex = 0;
    break;
  case 'End':
    newIndex = tabs.length - 1;
    break;
  default:
    return;
}

e.preventDefault();
setActiveTab(tabs[newIndex].id);
tabRefs.current[newIndex]?.focus();

};

return ( <div> <div role="tablist" aria-label="Content tabs" className="flex border-b"> {tabs.map((tab, index) => ( <button key={tab.id} ref={(el) => (tabRefs.current[index] = el)} role="tab" id={tab-${tab.id}} aria-selected={activeTab === tab.id} aria-controls={panel-${tab.id}} tabIndex={activeTab === tab.id ? 0 : -1} onClick={() => setActiveTab(tab.id)} onKeyDown={(e) => handleKeyDown(e, index)} className={px-4 py-2 ${ activeTab === tab.id ? 'border-b-2 border-blue-500' : 'text-gray-500' }} > {tab.label} </button> ))} </div>

  {tabs.map((tab) => (
    &#x3C;div
      key={tab.id}
      role="tabpanel"
      id={`panel-${tab.id}`}
      aria-labelledby={`tab-${tab.id}`}
      hidden={activeTab !== tab.id}
      tabIndex={0}
      className="p-4"
    >
      {tab.content}
    &#x3C;/div>
  ))}
&#x3C;/div>

); }

Dropdown Menu

// components/Dropdown.tsx import { useState, useRef, useEffect, KeyboardEvent } from 'react';

interface MenuItem { id: string; label: string; onClick: () => void; }

export function Dropdown({ label, items }: { label: string; items: MenuItem[] }) { const [isOpen, setIsOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const menuRef = useRef<HTMLUListElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);

const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); if (!isOpen) { setIsOpen(true); setActiveIndex(0); } else { setActiveIndex((prev) => (prev + 1) % items.length); } break; case 'ArrowUp': e.preventDefault(); setActiveIndex((prev) => (prev - 1 + items.length) % items.length); break; case 'Enter': case ' ': e.preventDefault(); if (isOpen && activeIndex >= 0) { items[activeIndex].onClick(); setIsOpen(false); buttonRef.current?.focus(); } else { setIsOpen(true); } break; case 'Escape': setIsOpen(false); buttonRef.current?.focus(); break; } };

// Close on outside click useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { setIsOpen(false); } };

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);

}, []);

return ( <div className="relative"> <button ref={buttonRef} aria-haspopup="true" aria-expanded={isOpen} aria-controls="dropdown-menu" onClick={() => setIsOpen(!isOpen)} onKeyDown={handleKeyDown} className="px-4 py-2 bg-gray-100 rounded" > {label} </button>

  {isOpen &#x26;&#x26; (
    &#x3C;ul
      ref={menuRef}
      id="dropdown-menu"
      role="menu"
      aria-labelledby="dropdown-button"
      onKeyDown={handleKeyDown}
      className="absolute mt-1 bg-white border rounded shadow-lg"
    >
      {items.map((item, index) => (
        &#x3C;li
          key={item.id}
          role="menuitem"
          tabIndex={-1}
          onClick={() => {
            item.onClick();
            setIsOpen(false);
          }}
          className={`px-4 py-2 cursor-pointer ${
            index === activeIndex ? 'bg-blue-100' : ''
          }`}
        >
          {item.label}
        &#x3C;/li>
      ))}
    &#x3C;/ul>
  )}
&#x3C;/div>

); }

Focus Management

Skip Links

<!-- First element in body --> <a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:p-4 focus:bg-white focus:z-50"> Skip to main content </a>

<!-- Main content target --> <main id="main-content" tabindex="-1"> ... </main>

Focus Trap for Modals

// hooks/useFocusTrap.ts import { useEffect, useRef } from 'react';

export function useFocusTrap<T extends HTMLElement>(isActive: boolean) { const containerRef = useRef<T>(null);

useEffect(() => { if (!isActive || !containerRef.current) return;

const container = containerRef.current;
const focusableElements = container.querySelectorAll&#x3C;HTMLElement>(
  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];

const handleTab = (e: KeyboardEvent) => {
  if (e.key !== 'Tab') return;

  if (e.shiftKey) {
    if (document.activeElement === firstElement) {
      e.preventDefault();
      lastElement?.focus();
    }
  } else {
    if (document.activeElement === lastElement) {
      e.preventDefault();
      firstElement?.focus();
    }
  }
};

container.addEventListener('keydown', handleTab);
firstElement?.focus();

return () => container.removeEventListener('keydown', handleTab);

}, [isActive]);

return containerRef; }

Focus Visible Styles

/* Only show focus ring for keyboard users */ :focus { outline: none; }

:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }

/* Tailwind equivalent */ .focus-visible:focus-visible { @apply outline-none ring-2 ring-blue-500 ring-offset-2; }

Color Contrast

WCAG Contrast Requirements

Level Normal Text Large Text

AA 4.5:1 3:1

AAA 7:1 4.5:1

Large text = 18pt+ (24px) or 14pt+ bold (18.5px)

Accessible Color Pairs

/* High contrast pairs / :root { / Text on white background / --text-primary: #1f2937; / gray-800, 12.6:1 contrast / --text-secondary: #4b5563; / gray-600, 7.0:1 contrast / --text-tertiary: #6b7280; / gray-500, 4.6:1 contrast (AA only) */

/* Links / --link-color: #1d4ed8; / blue-700, 7.3:1 contrast */

/* Errors / --error-text: #dc2626; / red-600, 4.5:1 contrast */ }

Testing Contrast

// Utility to check contrast ratio function getContrastRatio(color1: string, color2: string): number { const getLuminance = (hex: string): number => { const rgb = parseInt(hex.slice(1), 16); const r = (rgb >> 16) & 0xff; const g = (rgb >> 8) & 0xff; const b = (rgb >> 0) & 0xff;

const [rs, gs, bs] = [r, g, b].map((c) => {
  c /= 255;
  return c &#x3C;= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});

return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;

};

const l1 = getLuminance(color1); const l2 = getLuminance(color2); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2);

return (lighter + 0.05) / (darker + 0.05); }

// Usage const ratio = getContrastRatio('#1f2937', '#ffffff'); // 12.6 const passesAA = ratio >= 4.5; const passesAAA = ratio >= 7;

Forms

Accessible Form Fields

// components/FormField.tsx interface FormFieldProps { id: string; label: string; error?: string; required?: boolean; description?: string; children: React.ReactNode; }

export function FormField({ id, label, error, required, description, children, }: FormFieldProps) { const descriptionId = description ? ${id}-description : undefined; const errorId = error ? ${id}-error : undefined;

return ( <div className="space-y-1"> <label htmlFor={id} className="block font-medium"> {label} {required && ( <span className="text-red-500 ml-1" aria-hidden="true"> * </span> )} {required && <span className="sr-only">(required)</span>} </label>

  {description &#x26;&#x26; (
    &#x3C;p id={descriptionId} className="text-sm text-gray-500">
      {description}
    &#x3C;/p>
  )}

  {/* Clone child and add aria attributes */}
  {React.cloneElement(children as React.ReactElement, {
    id,
    'aria-required': required,
    'aria-invalid': !!error,
    'aria-describedby': [descriptionId, errorId].filter(Boolean).join(' ') || undefined,
  })}

  {error &#x26;&#x26; (
    &#x3C;p id={errorId} className="text-sm text-red-600" role="alert">
      {error}
    &#x3C;/p>
  )}
&#x3C;/div>

); }

Error Announcements

// components/LiveRegion.tsx export function LiveRegion({ message }: { message: string }) { return ( <div role="alert" aria-live="polite" aria-atomic="true" className="sr-only" > {message} </div> ); }

// Usage: Announce form submission result const [announcement, setAnnouncement] = useState('');

const handleSubmit = async () => { try { await submitForm(); setAnnouncement('Form submitted successfully'); } catch { setAnnouncement('Error submitting form. Please try again.'); } };

Images and Media

Image Alt Text

<!-- Informative image --> <img src="chart.png" alt="Sales increased 25% from Q1 to Q2 2024" />

<!-- Decorative image --> <img src="decoration.svg" alt="" role="presentation" />

<!-- Complex image with long description --> <figure> <img src="infographic.png" alt="Company growth infographic" aria-describedby="infographic-desc" /> <figcaption id="infographic-desc"> Detailed description of the infographic... </figcaption> </figure>

Video Accessibility

<video controls> <source src="video.mp4" type="video/mp4" /> <track kind="captions" src="captions-en.vtt" srclang="en" label="English" default /> <track kind="descriptions" src="descriptions.vtt" srclang="en" label="Audio descriptions" /> </video>

Screen Reader Utilities

Tailwind SR-Only Classes

/* Already in Tailwind */ .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; }

.not-sr-only { position: static; width: auto; height: auto; padding: 0; margin: 0; overflow: visible; clip: auto; white-space: normal; }

Screen Reader Only Text

// components/VisuallyHidden.tsx export function VisuallyHidden({ children }: { children: React.ReactNode }) { return <span className="sr-only">{children}</span>; }

// Usage <button> <TrashIcon aria-hidden="true" /> <VisuallyHidden>Delete item</VisuallyHidden> </button>

Testing Tools

Automated Testing

// jest-axe for unit tests import { axe, toHaveNoViolations } from 'jest-axe'; import { render } from '@testing-library/react';

expect.extend(toHaveNoViolations);

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

Playwright a11y Testing

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

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

const accessibilityScanResults = await new AxeBuilder({ page }).analyze();

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

test('keyboard navigation works', async ({ page }) => { await page.goto('/');

// Tab through interactive elements await page.keyboard.press('Tab'); const firstFocused = await page.evaluate(() => document.activeElement?.tagName); expect(['A', 'BUTTON', 'INPUT']).toContain(firstFocused);

// Test skip link await page.keyboard.press('Enter'); await expect(page.locator('#main-content')).toBeFocused(); });

Manual Testing Checklist

  • Navigate entire page with keyboard only

  • Test with screen reader (VoiceOver, NVDA)

  • Zoom to 200% - layout still usable

  • Check color contrast with browser tools

  • Verify focus indicators are visible

  • Test with reduced motion preference

  • Verify form error announcements

Best Practices

  • Semantic HTML first: Use native elements before ARIA

  • Focus management: Never remove focus outlines without replacement

  • Announce changes: Use live regions for dynamic content

  • Test with users: Include disabled users in testing

  • Progressive enhancement: Core functionality without JavaScript

  • Color independence: Don't rely on color alone for meaning

  • Touch targets: Minimum 44x44px for mobile

  • Animation: Respect prefers-reduced-motion

Output Checklist

Every accessibility audit should verify:

  • Semantic HTML used throughout

  • Proper heading hierarchy (h1 → h2 → h3)

  • All interactive elements keyboard accessible

  • Focus visible on all focusable elements

  • Images have appropriate alt text

  • Form fields have associated labels

  • Error messages linked with aria-describedby

  • Color contrast meets WCAG AA (4.5:1)

  • Skip link to main content

  • ARIA attributes used correctly

  • Modal focus trap implemented

  • Dynamic content announced to screen readers

  • Tested with axe-core or similar

  • Manual screen reader testing completed

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Security

input-validation-sanitization-auditor

No summary provided by upstream source.

Repository SourceNeeds Review
Security

dependency-vulnerability-triage

No summary provided by upstream source.

Repository SourceNeeds Review
Security

api-security-hardener

No summary provided by upstream source.

Repository SourceNeeds Review
Security

threat-model-generator

No summary provided by upstream source.

Repository SourceNeeds Review