skills/laurigates/claude-plugins/accessibility-implementation

accessibility-implementation

SKILL.md

Accessibility Implementation

Technical implementation of WCAG guidelines, ARIA patterns, and assistive technology support.

Core Expertise

  • WCAG Compliance: Implementing WCAG 2.1/2.2 success criteria in code
  • ARIA Patterns: Correct usage of roles, states, and properties
  • Keyboard Navigation: Focus management, key handlers, logical tab order
  • Screen Readers: Content structure, announcements, live regions
  • Testing: Automated and manual accessibility testing

WCAG Quick Reference

Level A (Must Have)

Criterion Implementation
1.1.1 Non-text Content alt for images, labels for inputs
1.3.1 Info and Relationships Semantic HTML, ARIA relationships
2.1.1 Keyboard All interactive elements keyboard accessible
2.4.1 Bypass Blocks Skip links, landmarks
4.1.2 Name, Role, Value ARIA labels, roles for custom widgets

Level AA (Should Have)

Criterion Implementation
1.4.3 Contrast (Minimum) 4.5:1 text, 3:1 large text
1.4.11 Non-text Contrast 3:1 for UI components
2.4.6 Headings and Labels Descriptive, hierarchical headings
2.4.7 Focus Visible Visible focus indicator (2px+ outline)

ARIA Patterns

Buttons and Links

<!-- Custom button -->
<div role="button" tabindex="0"
     aria-pressed="false"
     onkeydown="handleKeyDown(event)">
  Toggle Feature
</div>

<!-- Icon button (needs accessible name) -->
<button aria-label="Close dialog">
  <svg aria-hidden="true">...</svg>
</button>

<!-- Link vs button -->
<!-- Use link for navigation, button for actions -->
<a href="/page">Go to page</a>
<button type="button">Submit form</button>

Form Controls

<!-- Input with label -->
<label for="email">Email address</label>
<input id="email" type="email"
       aria-describedby="email-hint email-error"
       aria-invalid="true"
       required>
<div id="email-hint">We'll never share your email</div>
<div id="email-error" role="alert">Please enter a valid email</div>

<!-- Checkbox group -->
<fieldset>
  <legend>Notification preferences</legend>
  <label><input type="checkbox" name="notif" value="email"> Email</label>
  <label><input type="checkbox" name="notif" value="sms"> SMS</label>
</fieldset>

<!-- Combobox (autocomplete) -->
<label for="country">Country</label>
<input id="country"
       role="combobox"
       aria-expanded="false"
       aria-autocomplete="list"
       aria-controls="country-listbox">
<ul id="country-listbox" role="listbox" hidden>
  <li role="option" id="opt-us">United States</li>
  <li role="option" id="opt-uk">United Kingdom</li>
</ul>

Modal Dialog

<div role="dialog"
     aria-modal="true"
     aria-labelledby="dialog-title"
     aria-describedby="dialog-desc">
  <h2 id="dialog-title">Confirm Action</h2>
  <p id="dialog-desc">Are you sure you want to proceed?</p>
  <button>Cancel</button>
  <button>Confirm</button>
</div>
// Focus trap implementation
function trapFocus(dialog: HTMLElement) {
  const focusable = dialog.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0] as HTMLElement;
  const last = focusable[focusable.length - 1] as HTMLElement;

  dialog.addEventListener('keydown', (e) => {
    if (e.key === 'Tab') {
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
    if (e.key === 'Escape') {
      closeDialog();
    }
  });

  // Move focus to first element
  first.focus();
}

Tabs

<div role="tablist" aria-label="Settings tabs">
  <button role="tab"
          id="tab-1"
          aria-selected="true"
          aria-controls="panel-1">
    General
  </button>
  <button role="tab"
          id="tab-2"
          aria-selected="false"
          aria-controls="panel-2"
          tabindex="-1">
    Privacy
  </button>
</div>

<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
  General settings content
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
  Privacy settings content
</div>
// Tab keyboard navigation
tablist.addEventListener('keydown', (e) => {
  const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
  const current = tabs.indexOf(document.activeElement as Element);

  let next: number;
  switch (e.key) {
    case 'ArrowRight':
      next = (current + 1) % tabs.length;
      break;
    case 'ArrowLeft':
      next = (current - 1 + tabs.length) % tabs.length;
      break;
    case 'Home':
      next = 0;
      break;
    case 'End':
      next = tabs.length - 1;
      break;
    default:
      return;
  }

  e.preventDefault();
  (tabs[next] as HTMLElement).focus();
  activateTab(tabs[next]);
});

Live Regions

<!-- Status messages -->
<div role="status" aria-live="polite">
  Form saved successfully
</div>

<!-- Alerts (interrupts) -->
<div role="alert" aria-live="assertive">
  Error: Connection lost
</div>

<!-- Progress updates -->
<div aria-live="polite" aria-atomic="true">
  Loading: 45% complete
</div>

Keyboard Navigation

Standard Key Bindings

Key Behavior
Tab Move to next focusable element
Shift+Tab Move to previous focusable element
Enter/Space Activate button, select option
Escape Close modal, cancel operation
Arrow keys Navigate within component (tabs, menu, listbox)
Home/End Go to first/last item in list

Focus Management

// Return focus after modal close
const triggerElement = document.activeElement;
openModal();
// On close:
closeModal();
triggerElement?.focus();

// Move focus to error
function showValidationErrors() {
  const firstError = document.querySelector('[aria-invalid="true"]');
  (firstError as HTMLElement)?.focus();
}

// Skip link
<a href="#main-content" class="skip-link">Skip to main content</a>
<main id="main-content" tabindex="-1">...</main>

Roving Tabindex

// For composite widgets (toolbar, menu, tabs)
function setRovingTabindex(container: HTMLElement, selector: string) {
  const items = container.querySelectorAll(selector);

  items.forEach((item, index) => {
    item.setAttribute('tabindex', index === 0 ? '0' : '-1');
  });

  container.addEventListener('keydown', (e) => {
    const current = Array.from(items).indexOf(document.activeElement as Element);
    let next = current;

    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
      next = (current + 1) % items.length;
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
      next = (current - 1 + items.length) % items.length;
    }

    if (next !== current) {
      items[current].setAttribute('tabindex', '-1');
      items[next].setAttribute('tabindex', '0');
      (items[next] as HTMLElement).focus();
      e.preventDefault();
    }
  });
}

Testing

Automated Testing

# axe-core CLI
npx @axe-core/cli https://localhost:3000

# Lighthouse accessibility audit
npx lighthouse http://localhost:3000 --only-categories=accessibility --output=json

# pa11y
npx pa11y http://localhost:3000

# jest-axe for unit tests
npm install --save-dev jest-axe
// jest-axe example
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('component is accessible', async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});
// Playwright accessibility testing
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

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

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

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

Manual Testing Checklist

Keyboard Navigation

  • All interactive elements reachable via Tab
  • Focus order matches visual order
  • Focus indicator always visible
  • No keyboard traps
  • Escape closes modals/menus

Screen Reader Testing

  • VoiceOver (macOS): Cmd+F5
  • NVDA (Windows): Free download
  • Test: Links announce destination
  • Test: Forms announce labels and errors
  • Test: Dynamic content announced

Visual Testing

  • Zoom to 200% without horizontal scroll
  • Color contrast meets ratios
  • Information not conveyed by color alone
  • Focus indicators visible in all themes

Common Fixes

Missing Accessible Name

<!-- Bad: Icon button without label -->
<button><svg>...</svg></button>

<!-- Good: Add aria-label -->
<button aria-label="Close">
  <svg aria-hidden="true">...</svg>
</button>

Missing Form Labels

<!-- Bad: Placeholder as label -->
<input placeholder="Email">

<!-- Good: Proper label -->
<label for="email">Email</label>
<input id="email" type="email">

<!-- Good: Visually hidden label -->
<label for="search" class="visually-hidden">Search</label>
<input id="search" type="search" placeholder="Search...">

Missing Heading Structure

<!-- Bad: Skipping heading levels -->
<h1>Page Title</h1>
<h3>Section</h3>  <!-- Missing h2 -->

<!-- Good: Proper hierarchy -->
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>

Focus Not Visible

/* Bad: Removing focus outline */
button:focus { outline: none; }

/* Good: Custom focus indicator */
button:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

Color Contrast

/* Bad: Low contrast */
.text { color: #999; background: #fff; } /* 2.85:1 ratio */

/* Good: Sufficient contrast */
.text { color: #595959; background: #fff; } /* 4.56:1 ratio */

CSS Utilities

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

/* Skip link */
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  padding: 8px;
  background: #000;
  color: #fff;
  z-index: 100;
}

.skip-link:focus {
  top: 0;
}

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

Best Practices

Semantic HTML First

Use native HTML elements before ARIA. A <button> is better than <div role="button">.

Don't Override Default Behavior

Native elements have built-in accessibility. Don't break it with JavaScript.

Test with Real Users

Automated tools catch ~30% of issues. Manual testing with assistive technology is essential.

Provide Multiple Ways

Offer keyboard, mouse, and touch alternatives for all interactions.

References

Weekly Installs
52
GitHub Stars
13
First Seen
Jan 29, 2026
Installed on
opencode51
github-copilot51
gemini-cli50
codex50
kimi-cli50
amp50