NYC
skills/daffy0208/ai-dev-standards/Accessibility Engineer

Accessibility Engineer

SKILL.md

Accessibility Engineer

Build for everyone - accessibility is not optional.

Core Principle

Accessibility is a civil right, not a feature.

1 in 4 adults in the US has a disability. Accessible design benefits everyone:

  • Blind users (screen readers)
  • Low vision users (zoom, high contrast)
  • Deaf users (captions)
  • Motor disabilities (keyboard-only)
  • Cognitive disabilities (clear language)
  • Temporary disabilities (broken arm)
  • Situational limitations (bright sunlight, noisy environment)

WCAG Compliance Levels

Level A: Minimum (legal requirement) Level AA: Industry standard (aim for this) Level AAA: Gold standard (difficult to achieve for all content)

Target: WCAG 2.1 AA compliance


Pillar 1: Semantic HTML

Use the Right Elements

// ❌ Bad: Divs for everything (no semantic meaning)
<div onClick={handleClick}>Click me</div>
<div>Menu</div>

// ✅ Good: Semantic HTML
<button onClick={handleClick}>Click me</button>
<nav>Menu</nav>

Document Structure

// ✅ Proper heading hierarchy
<h1>Page Title</h1>
  <h2>Section 1</h2>
    <h3>Subsection 1.1</h3>
  <h2>Section 2</h2>

// ❌ Bad: Skipping levels
<h1>Page Title</h1>
  <h4>Section 1</h4> // Skipped h2, h3

Landmarks

<header>
  <nav aria-label="Main navigation">
    {/* Navigation links */}
  </nav>
</header>

<main>
  <article>
    {/* Main content */}
  </article>

  <aside>
    {/* Sidebar */}
  </aside>
</main>

<footer>
  {/* Footer content */}
</footer>

Pillar 2: Keyboard Navigation

All Interactive Elements Must Be Keyboard Accessible

// ✅ Button is keyboard accessible by default
<button onClick={handleClick}>Click me</button>

// ❌ Div requires extra work
<div onClick={handleClick}>Click me</div> // Can't tab to it!

// ✅ If you must use div, add keyboard support
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      handleClick()
    }
  }}
>
  Click me
</div>

Tab Order

// ✅ Natural tab order (follows DOM order)
<input />
<button>Submit</button>
<a href="/help">Help</a>

// ❌ Don't use tabIndex > 0 (breaks natural order)
<button tabIndex={5}>Button</button> // Anti-pattern!

// ✅ tabIndex=-1 to remove from tab order
<div tabIndex={-1}>Not keyboard focusable</div>

Focus Management

// Modal: Trap focus inside
function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef()

  useEffect(() => {
    if (!isOpen) return

    // Focus first focusable element
    const firstFocusable = modalRef.current.querySelector('button, input, a')
    firstFocusable?.focus()

    // Trap focus
    function handleTab(e) {
      if (e.key !== 'Tab') return

      const focusableElements = modalRef.current.querySelectorAll(
        'button, input, a, [tabindex]:not([tabindex="-1"])'
      )

      const first = focusableElements[0]
      const last = focusableElements[focusableElements.length - 1]

      if (e.shiftKey) {
        if (document.activeElement === first) {
          last.focus()
          e.preventDefault()
        }
      } else {
        if (document.activeElement === last) {
          first.focus()
          e.preventDefault()
        }
      }
    }

    document.addEventListener('keydown', handleTab)
    return () => document.removeEventListener('keydown', handleTab)
  }, [isOpen])

  return isOpen ? (
    <div role="dialog" aria-modal="true" ref={modalRef}>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  ) : null
}

Skip Links

// Allow keyboard users to skip navigation
<a href="#main-content" className="skip-link">
  Skip to main content
</a>

<nav>{/* Navigation */}</nav>

<main id="main-content">
  {/* Main content */}
</main>

// CSS
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: #fff;
  padding: 8px;
}

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

Pillar 3: ARIA Attributes

Only Use ARIA When Semantic HTML Isn't Enough

// ✅ Semantic HTML (no ARIA needed)
<button>Click me</button>

// ❌ Unnecessary ARIA
<button role="button" aria-label="Click me">Click me</button>

// ✅ ARIA needed (custom widget)
<div role="tab" aria-selected={isActive} aria-controls="panel-1">
  Tab 1
</div>

Common ARIA Attributes

aria-label - Provides accessible name:

<button aria-label="Close dialog">
  <XIcon /> {/* Visual only */}
</button>

<input type="search" aria-label="Search products" />

aria-labelledby - References another element:

<h2 id="dialog-title">Delete Account</h2>
<div role="dialog" aria-labelledby="dialog-title">
  {/* Dialog content */}
</div>

aria-describedby - Additional description:

<input
  type="password"
  aria-describedby="password-requirements"
/>
<div id="password-requirements">
  Must be at least 8 characters
</div>

aria-live - Announce dynamic content:

// Polite: Wait for user to finish
<div aria-live="polite">
  {itemsAddedToCart} items added to cart
</div>

// Assertive: Interrupt immediately (use sparingly)
<div aria-live="assertive" role="alert">
  Error: Payment failed
</div>

aria-expanded - Collapsible content:

<button
  aria-expanded={isOpen}
  aria-controls="dropdown-menu"
  onClick={() => setIsOpen(!isOpen)}
>
  Menu
</button>

<div id="dropdown-menu" hidden={!isOpen}>
  {/* Menu items */}
</div>

aria-hidden - Hide from screen readers:

// Decorative icons
<span aria-hidden="true"></span>

// Don't hide interactive elements!
// ❌ Bad
<button aria-hidden="true">Click me</button>

Pillar 4: Forms & Inputs

Labels

// ✅ Good: Explicit label
<label htmlFor="email">Email</label>
<input id="email" type="email" />

// ✅ Good: Implicit label
<label>
  Email
  <input type="email" />
</label>

// ❌ Bad: No label (placeholder is not a label!)
<input type="email" placeholder="Email" />

Error Messages

function EmailInput({ error }) {
  const errorId = 'email-error'

  return (
    <>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
      />
      {error && (
        <div id={errorId} role="alert">
          {error}
        </div>
      )}
    </>
  )
}

Required Fields

<label htmlFor="name">
  Name <span aria-label="required">*</span>
</label>
<input id="name" type="text" required aria-required="true" />

Pillar 5: Color & Contrast

Minimum Contrast Ratios (WCAG AA)

  • Normal text (< 18pt): 4.5:1
  • Large text (≥ 18pt or 14pt bold): 3:1
  • UI components: 3:1
// ❌ Bad: Insufficient contrast
<button style={{ background: '#ddd', color: '#aaa' }}>
  Submit // 1.5:1 contrast - fails!
</button>

// ✅ Good: Sufficient contrast
<button style={{ background: '#0066cc', color: '#ffffff' }}>
  Submit // 8:1 contrast - passes!
</button>

Don't Use Color Alone

// ❌ Bad: Color only
<span style={{ color: 'red' }}>Error</span>
<span style={{ color: 'green' }}>Success</span>

// ✅ Good: Color + icon/text
<span style={{ color: 'red' }}>
  <ErrorIcon aria-hidden="true" />
  Error
</span>

Pillar 6: Images & Media

Alt Text

// ✅ Informative images
<img src="chart.png" alt="Sales increased 50% in Q4" />

// ✅ Decorative images
<img src="decorative-border.png" alt="" /> // Empty alt

// ❌ Bad: No alt or redundant alt
<img src="photo.jpg" /> // Missing alt
<img src="photo.jpg" alt="Photo" /> // Useless

Complex Images

<figure>
  <img src="complex-chart.png" alt="Sales data for 2024" />
  <figcaption>
    <details>
      <summary>Detailed description</summary>
      <p>Q1: $100k, Q2: $150k, Q3: $180k, Q4: $220k. Shows 50% growth year-over-year.</p>
    </details>
  </figcaption>
</figure>

Video Captions

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

Pillar 7: Testing

Automated Testing

# Lighthouse accessibility audit
lighthouse https://example.com --only-categories=accessibility

# axe-core (Jest)
npm install --save-dev @axe-core/react jest-axe
// Test with jest-axe
import { axe, toHaveNoViolations } from 'jest-axe'

expect.extend(toHaveNoViolations)

it('has no accessibility violations', async () => {
  const { container } = render(<Button>Click me</Button>)
  const results = await axe(container)

  expect(results).toHaveNoViolations()
})

Manual Testing

Keyboard Navigation:

  • Tab through entire page
  • Enter/Space to activate buttons
  • Arrow keys for radio groups
  • Esc to close modals

Screen Reader Testing:

  • NVDA (Windows, free)
  • JAWS (Windows, paid)
  • VoiceOver (Mac, built-in)

Screen Reader Shortcuts:

  • Navigate by headings: H (next), Shift+H (previous)
  • Navigate by landmarks: D (next), Shift+D (previous)
  • List all links: Insert+F7 (NVDA)

Common Patterns

Accessible Button

<button
  type="button"
  onClick={handleClick}
  disabled={isDisabled}
  aria-busy={isLoading}
  aria-label={ariaLabel}
>
  {children}
</button>

Accessible Modal

<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-description"
>
  <h2 id="dialog-title">Dialog Title</h2>
  <p id="dialog-description">Dialog description</p>

  <button onClick={onClose}>Close</button>
</div>

Accessible Tabs

<div>
  <div role="tablist">
    <button
      role="tab"
      aria-selected={activeTab === 'tab1'}
      aria-controls="panel1"
      onClick={() => setActiveTab('tab1')}
    >
      Tab 1
    </button>
    <button
      role="tab"
      aria-selected={activeTab === 'tab2'}
      aria-controls="panel2"
      onClick={() => setActiveTab('tab2')}
    >
      Tab 2
    </button>
  </div>

  <div id="panel1" role="tabpanel" hidden={activeTab !== 'tab1'}>
    Panel 1 content
  </div>

  <div id="panel2" role="tabpanel" hidden={activeTab !== 'tab2'}>
    Panel 2 content
  </div>
</div>

Accessibility Checklist

Semantic HTML

  • Proper heading hierarchy (h1 → h2 → h3)
  • Semantic landmarks (header, nav, main, footer)
  • Lists use ul/ol/li
  • Buttons for actions, links for navigation

Keyboard

  • All interactive elements keyboard accessible
  • Visible focus indicators
  • Logical tab order
  • Skip links provided
  • No keyboard traps

ARIA

  • Semantic HTML used first (ARIA only when needed)
  • All interactive widgets have roles
  • Dynamic content has aria-live
  • Forms have proper labels and descriptions

Color & Contrast

  • Text contrast ≥ 4.5:1 (normal), ≥ 3:1 (large)
  • Don't use color alone to convey info
  • Focus indicators visible

Images & Media

  • All images have alt text
  • Decorative images have empty alt
  • Videos have captions
  • Audio has transcripts

Forms

  • All inputs have labels
  • Error messages associated with inputs
  • Required fields indicated

Testing

  • Keyboard navigation tested
  • Screen reader tested
  • Automated tools run (axe, Lighthouse)
  • Color blindness simulation tested

Tools

  • axe DevTools - Browser extension
  • Lighthouse - Built into Chrome DevTools
  • WAVE - Web accessibility evaluation tool
  • Color Contrast Analyzer - Desktop app
  • NVDA - Free screen reader (Windows)
  • VoiceOver - Built-in screen reader (Mac)

Related Resources

Skills:

  • ux-designer - Accessible design patterns
  • frontend-builder - Accessible React components
  • testing-strategist - Accessibility testing

External:


Build for everyone.

Weekly Installs
0
First Seen
Jan 1, 1970