diff --git a/skills/frontend-accessibility/REFERENCE.md b/skills/frontend-accessibility/REFERENCE.md new file mode 100644 index 0000000..84b8f81 --- /dev/null +++ b/skills/frontend-accessibility/REFERENCE.md @@ -0,0 +1,428 @@ +# Frontend Accessibility Reference Guide + +This document provides comprehensive WCAG 2.1 AA + 2.2 guidelines, detailed implementation patterns, and testing methodology. + +## WCAG 2.1 AA Quick Reference + +### Perceivable + +| Criterion | Requirement | Implementation | +|-----------|-------------|----------------| +| 1.1.1 | Non-text content has text alternative | `alt` attributes, `aria-label` | +| 1.3.1 | Info and relationships programmatically determined | Semantic HTML, proper heading hierarchy | +| 1.3.2 | Meaningful sequence | Logical DOM order, CSS doesn't break reading order | +| 1.4.1 | Use of color not only way to convey info | Add icons, patterns, text labels | +| 1.4.3 | Contrast minimum (4.5:1) | Verify color pairs | +| 1.4.4 | Resize text to 200% | Responsive design, no pixel dependencies | +| 1.4.10 | Reflow (no horizontal scroll at 320px) | CSS containment, responsive containers | +| 1.4.11 | Non-text contrast (3:1) | UI components, graphics | +| 1.4.12 | Text spacing adjustable | Support line-height, letter-spacing changes | + +### Operable + +| Criterion | Requirement | Implementation | +|-----------|-------------|----------------| +| 2.1.1 | Keyboard accessible | All functionality via keyboard | +| 2.1.2 | No keyboard trap | Focus can always be moved away | +| 2.4.1 | Bypass blocks | Skip links | +| 2.4.2 | Page titled | Descriptive `` elements | +| 2.4.3 | Focus order | Logical tab sequence | +| 2.4.4 | Link purpose (from context) | Descriptive link text | +| 2.4.6 | Headings and labels | Descriptive headings, form labels | +| 2.4.7 | Focus visible | Custom `:focus-visible` styles | +| 2.5.1 | Pointer gestures | Support single-point activation | +| 2.5.3 | Label in name | Accessible name matches visible label | +| 2.5.5 | Target size (44x44px minimum) | Adequate touch targets | + +### Understandable + +| Criterion | Requirement | Implementation | +|-----------|-------------|----------------| +| 3.1.1 | Language of page | `lang` attribute on `<html>` | +| 3.2.1 | On focus no context change | Don't trigger actions on focus | +| 3.2.2 | On input no context change | No unexpected submissions | +| 3.3.1 | Error identification | Clear error messages | +| 3.3.2 | Labels or instructions | Visible labels for inputs | + +### Robust + +| Criterion | Requirement | Implementation | +|-----------|-------------|----------------| +| 4.1.1 | Parsing | Valid HTML | +| 4.1.2 | Name, role, value | Proper ARIA usage | + +### WCAG 2.2 New Criteria (2023) + +| Criterion | Requirement | Implementation | +|-----------|-------------|----------------| +| 2.4.11 | Focus not obscured (minimum) | Focused element fully visible | +| 2.4.12 | Focus not obscured (enhanced) | No content overlaps focused element | +| 3.3.7 | Redundant entry | Don't ask for info already provided | + +## Implementation Deep Dive + +### Form Accessibility + +#### Labels and Associations + +```jsx +// ✅ Explicit association via htmlFor +<label htmlFor="email">Email address</label> +<input id="email" type="email" /> + +// ✅ Implicit association (wrapped) +<label> + Email address + <input type="email" /> +</label> + +// ❌ No association - screen reader can't link them +<label>Email address</label> +<input type="email" /> +``` + +#### Error Handling + +Per [ARIA APG naming practices](https://www.w3.org/WAI/ARIA/apg/practices/names-and-descriptions/): + +```jsx +export function AccessibleFormField({ + label, + error, + id, + ...props +}) { + const errorId = `${id}-error`; + + return ( + <div className="form-field"> + <label htmlFor={id}>{label}</label> + <input + id={id} + aria-invalid={!!error} + aria-describedby={error ? errorId : undefined} + {...props} + /> + {error && ( + <p id={errorId} role="alert" className="text-red-600"> + {error} + </p> + )} + </div> + ); +} +``` + +**Note:** Use `aria-describedby` for errors instead of `aria-errormessage` for better browser/screen reader support. + +### Modal and Dialog Accessibility + +Following [ARIA APG Dialog Modal Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/): + +```jsx +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +export function AccessibleModal({ isOpen, onClose, title, children }) { + const modalRef = useRef(null); + const previousFocusRef = useRef(null); + + useEffect(() => { + if (isOpen) { + previousFocusRef.current = document.activeElement; + // Focus first element in modal + const focusable = modalRef.current.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + focusable?.focus(); + + // Trap focus + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + onClose(); + return; + } + + if (e.key === 'Tab') { + const focusableElements = modalRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey && document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } else if (!e.shiftKey && document.activeElement === lastElement) { + e.preventDefault(); + firstElement.focus(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + previousFocusRef.current?.focus(); + }; + } + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return createPortal( + <div + role="dialog" + aria-modal="true" + aria-labelledby="modal-title" + aria-describedby="modal-description" + ref={modalRef} + className="modal-overlay" + > + <h2 id="modal-title" className="sr-only">{title}</h2> + <p id="modal-description" className="sr-only">{description}</p> + {children} + <button onClick={onClose} aria-label="Close modal"> + <CloseIcon /> + </button> + </div>, + document.body + ); +} +``` + +**Key patterns per ARIA APG:** +- Focus the **first meaningful element** inside the dialog, not the dialog itself +- Use `aria-describedby` for additional context beyond the title +- Always restore focus to the previously focused element when closing + +### Dynamic Content and Live Regions + +Per [ARIA APG Live Regions](https://www.w3.org/WAI/ARIA/apg/practices/live-regions/): + +```jsx +// Polite updates (announced when user is idle) +// Always use aria-atomic="true" to announce entire region, not just changed parts +function StatusMessage({ message }) { + return ( + <div aria-live="polite" aria-atomic="true" className="status"> + {message} + </div> + ); +} + +// Assertive updates (announced immediately - use sparingly) +function ErrorAlert({ error }) { + return ( + <div role="alert" aria-live="assertive" className="error-banner"> + {error} + </div> + ); +} + +// Loading state +function LoadingIndicator() { + return ( + <div + aria-live="polite" + aria-busy="true" + aria-label="Loading content" + > + <Spinner /> + </div> + ); +} +``` + +### Reduced Motion Implementation + +#### CSS-Only Approach + +```css +@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; + } +} +``` + +#### JavaScript Detection + +```jsx +import { useReducedMotion } from 'framer-motion'; + +function AnimatedComponent() { + const shouldReduceMotion = useReducedMotion(); + + const animation = shouldReduceMotion + ? { opacity: 1 } // Instant, no animation + : { + opacity: [0, 1], + scale: [0.8, 1], + transition: { duration: 0.5 } + }; + + return <motion.div animate={animation} />; +} +``` + +#### Custom Hook + +```jsx +import { useState, useEffect } from 'react'; + +function usePrefersReducedMotion() { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + setPrefersReducedMotion(mediaQuery.matches); + + const handler = (event) => { + setPrefersReducedMotion(event.matches); + }; + + mediaQuery.addEventListener('change', handler); + return () => mediaQuery.removeEventListener('change', handler); + }, []); + + return prefersReducedMotion; +} +``` + +### Color Contrast Tools and Verification + +#### Common Contrast Ratios + +| Combination | Ratio | AA Normal | AA Large | AAA Normal | AAA Large | +|-------------|-------|-----------|----------|------------|-----------| +| Black on White | 21:1 | ✅ | ✅ | ✅ | ✅ | +| #333 on White | 12.6:1 | ✅ | ✅ | ✅ | ✅ | +| #666 on White | 7.5:1 | ✅ | ✅ | ✅ | ✅ | +| #767676 on White | 4.5:1 | ✅ | ✅ | ❌ | ✅ | +| #767676 on #FFF | 4.48:1 | ❌ | ✅ | ❌ | ✅ | +| #999 on White | 3.0:1 | ❌ | ✅ | ❌ | ❌ | + +#### Design System Token Examples + +```css +/* tokens.css - Semantic color tokens with guaranteed contrast */ +:root { + /* Text tokens - verified 4.5:1+ on background */ + --text-primary: #1a1a1a; /* 17.4:1 on white */ + --text-secondary: #4b5563; /* 7.5:1 on white */ + --text-disabled: #9ca3af; /* 3.0:1 on white - only for decoration */ + + /* UI tokens - verified 3:1+ against adjacent */ + --button-primary-bg: #2563eb; + --button-primary-text: #ffffff; /* 7.2:1 */ + --button-secondary-bg: #e5e7eb; + --button-secondary-text: #1f2937; /* 11.9:1 */ + + /* Focus tokens */ + --focus-ring: #2563eb; + --focus-ring-offset: #ffffff; +} +``` + +### Heading Hierarchy + +```jsx +// ✅ Correct hierarchy - no skipping levels +function PageStructure() { + return ( + <article> + <h1>Main Title</h1> + <p>Introduction paragraph</p> + + <section> + <h2>Section Title</h2> + <p>Section content</p> + + <section> + <h3>Subsection Title</h3> + <p>Subsection content</p> + </section> + </section> + </article> + ); +} + +// ❌ Bad hierarchy - skips h2 +function BadPageStructure() { + return ( + <article> + <h1>Main Title</h1> + <section> + <h3>Skipped h2!</h3> {/* Screen reader users will miss structure */} + </section> + </article> + ); +} +``` + +## Testing Checklist + +### Automated Testing + +- Run axe-core or Lighthouse accessibility audits +- Verify no critical accessibility errors +- Check color contrast passes + +### Keyboard Testing + +1. Press `Tab` to move through all interactive elements +2. Verify focus indicator is visible on every element +3. Press `Enter`/`Space` to activate buttons and links +4. Test modal opens and focuses correctly +5. Verify `Escape` closes modals/dropdowns +6. Check no keyboard traps (can always move focus forward/backward) + +### Screen Reader Testing + +Test with at least one of: +- **VoiceOver** (macOS/iOS) - `Cmd + F5` +- **NVDA** (Windows) - `Insert + Down Arrow` +- **JAWS** (Windows) +- **TalkBack** (Android) + +Check: +- All images have alt text +- Form fields are labeled +- Headings form a logical outline +- Links have meaningful text +- Dynamic content is announced + +### Touch Target Testing + +- Verify all interactive elements are at least 44x44px +- Check spacing between adjacent touch targets +- Test on actual mobile device when possible + +### Reduced Motion Testing + +1. Enable "Reduce Motion" in OS settings +2. Visit your application +3. Verify: + - No unnecessary animations play + - All functionality remains usable + - No animations that could cause discomfort + +## Resources + +- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/) +- [WCAG 2.2 Quick Reference](https://www.w3.org/WAI/WCAG22/quickref/) +- [ARIA Authoring Practices Guide (APG)](https://www.w3.org/WAI/ARIA/apg/) +- [ARIA APG - Dialog Modal Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) +- [ARIA APG - Names and Descriptions](https://www.w3.org/WAI/ARIA/apg/practices/names-and-descriptions/) +- [ARIA APG - Landmark Regions](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/) +- [ARIA APG - Live Regions](https://www.w3.org/WAI/ARIA/apg/practices/live-regions/) +- [A11y Project Checklist](https://www.a11yproject.com/checklist/) +- [MDN Accessibility Docs](https://developer.mozilla.org/en-US/docs/Web/Accessibility) +- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) +- [Framer Motion Reduced Motion](https://www.framer.com/motion/reduce-motion/) diff --git a/skills/frontend-accessibility/SKILL.md b/skills/frontend-accessibility/SKILL.md new file mode 100644 index 0000000..ccc695c --- /dev/null +++ b/skills/frontend-accessibility/SKILL.md @@ -0,0 +1,540 @@ +--- +name: frontend-accessibility +description: Enforce WCAG 2.1 AA compliance in visual UI code. Use when generating React/Next.js components, implementing animations, or reviewing designs for accessibility barriers. +--- + +# Frontend Accessibility (WCAG 2.1 AA + 2.2) + +## Core Principle + +**Accessibility is not optional. Every user deserves equal access to your product.** + +## When to Use + +This skill should be invoked when: + +- Generating new React/Next.js components +- Implementing animations, transitions, or motion effects +- Creating interactive elements (buttons, forms, modals) +- Reviewing existing code for accessibility barriers + +## Motion Safety Rules + +### Respect User Motion Preferences + +Always implement `prefers-reduced-motion` to honor system-level accessibility settings. + +```jsx +import { motion, useReducedMotion } from 'framer-motion'; + +export function AnimatedCard({ children }) { + const shouldReduceMotion = useReducedMotion(); + + return ( + <motion.div + initial={shouldReduceMotion ? false : { opacity: 0, y: 20 }} + animate={shouldReduceMotion ? false : { opacity: 1, y: 0 }} + transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.3 }} + > + {children} + </motion.div> + ); +} +``` + +### Safe Animation Patterns + +- **Duration**: Keep animations under 300ms for micro-interactions +- **Flashing**: Never create content that flashes more than 3 times per second +- **Scroll**: Disable parallax effects when reduced motion is preferred + +## Keyboard & Focus Management + +### Skip Links + +Every page must have a skip link as the first focusable element: + +```jsx +export function SkipLink() { + return ( + <a + href="#main-content" + className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-white focus:text-black focus:ring-2" + > + Skip to main content + </a> + ); +} +``` + +**Pure CSS alternative (no Tailwind required):** + +```css +.skip-link { + position: absolute; + left: -9999px; + z-index: 9999; + padding: 1rem; + background: white; + color: black; + text-decoration: none; +} + +.skip-link:focus { + left: 0; + top: 0; +} +``` + +```jsx +<a href="#main-content" className="skip-link"> + Skip to main content +</a> +``` + +### Focus Indicators + +Never remove focus indicators. Customize them instead: + +```jsx +/* ✅ Custom focus styles */ +:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +/* ❌ Never do this */ +:focus { + outline: none; +} +``` + +### Focus Traps + +Use focus traps in modals and dialogs. Following [ARIA APG dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/): + +```jsx +import { useRef, useEffect, useCallback } from 'react'; + +export function useFocusTrap(isActive, onEscape) { + const containerRef = useRef(null); + const previousFocusRef = useRef(null); + const isActiveRef = useRef(isActive); + + useEffect(() => { + isActiveRef.current = isActive; + }, [isActive]); + + const handleKeyDown = useCallback((e) => { + if (e.key === 'Escape') { + onEscape?.(); + return; + } + + if (e.key !== 'Tab') return; + if (!containerRef.current) return; + + const focusableElements = containerRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === firstElement || document.activeElement === document.body) { + e.preventDefault(); + lastElement.focus(); + } + } else { + if (document.activeElement === lastElement || document.activeElement === document.body) { + e.preventDefault(); + firstElement.focus(); + } + } + }, [onEscape]); + + useEffect(() => { + if (!isActive || !containerRef.current) return; + + // Store previous focus to restore later + previousFocusRef.current = document.activeElement; + + const focusableElements = containerRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + const firstElement = focusableElements[0]; + // Focus first element - ensure it's visible before focusing + firstElement?.focus(); + + document.addEventListener('keydown', handleKeyDown); + + return () => { + if (!isActiveRef.current) return; + document.removeEventListener('keydown', handleKeyDown); + // Restore focus when dialog closes + previousFocusRef.current?.focus(); + }; + }, [isActive, handleKeyDown]); + + return containerRef; +} +``` + +**Usage:** +```jsx +function Modal({ isOpen, onClose, title, description, children }) { + const modalRef = useFocusTrap(isOpen, onClose); + + if (!isOpen) return null; + + return ( + <div + role="dialog" + aria-modal="true" + aria-labelledby="modal-title" + aria-describedby="modal-description" + ref={modalRef} + > + <h2 id="modal-title" className="sr-only">{title}</h2> + <p id="modal-description" className="sr-only">{description}</p> + {children} + <button onClick={onClose} aria-label="Close modal">×</button> + </div> + ); +} +``` + +**Note:** Per [ARIA APG](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/), avoid making the dialog element itself focusable (`tabindex="-1"` on the dialog role). Focus the first meaningful element inside instead. + +### Accordion + +Use proper ARIA attributes for collapsible sections: + +```jsx +function Accordion({ items }) { + return ( + <div role="region" aria-label="Accordion"> + {items.map((item, index) => ( + <AccordionItem key={index} item={item} /> + ))} + </div> + ); +} + +function AccordionItem({ item }) { + const [isOpen, setIsOpen] = useState(false); + const buttonId = `accordion-header-${item.id}`; + const panelId = `accordion-panel-${item.id}`; + + return ( + <div> + <h3> + <button + id={buttonId} + aria-expanded={isOpen} + aria-controls={panelId} + onClick={() => setIsOpen(!isOpen)} + > + {item.title} + <span aria-hidden="true">{isOpen ? '−' : '+'}</span> + </button> + </h3> + <div + id={panelId} + role="region" + aria-labelledby={buttonId} + hidden={!isOpen} + > + {item.content} + </div> + </div> + ); +} +``` + +### Tabs + +Implement accessible tabbed interfaces: + +```jsx +function Tabs({ tabs }) { + const [activeIndex, setActiveIndex] = useState(0); + + return ( + <div role="tablist" aria-label="Content tabs"> + <div role="presentation"> + {tabs.map((tab, index) => ( + <button + key={index} + role="tab" + aria-selected={index === activeIndex} + aria-controls={`tabpanel-${index}`} + id={`tab-${index}`} + onClick={() => setActiveIndex(index)} + > + {tab.label} + </button> + ))} + </div> + {tabs.map((tab, index) => ( + <div + key={index} + id={`tabpanel-${index}`} + role="tabpanel" + aria-labelledby={`tab-${index}`} + tabIndex={0} + hidden={index !== activeIndex} + > + {tab.content} + </div> + ))} + </div> + ); +} +``` + +**Keyboard navigation for tabs:** +- Arrow keys to move between tabs +- Home/End to jump to first/last tab +- Tab to move to the active tab panel + +## Screen Reader Support + +### Semantic HTML + +Use the right element for the right purpose: + +```jsx +// ✅ Semantic - screen readers understand the structure +<nav> + <ul> + <li><a href="/">Home</a></li> + <li><a href="/about">About</a></li> + </ul> +</nav> + +// ❌ Non-semantic - loses meaning +<div> + <div> + <div><a href="/">Home</a></div> + <div><a href="/about">About</a></div> + </div> +</div> +``` + +### Landmark Regions + +Use semantic elements and ARIA landmarks to help screen reader users navigate. Per [ARIA APG landmark regions](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/): + +```jsx +{/* ✅ Use native HTML5 elements (recommended) */} +<header> + <nav aria-label="Main"> + {/* nav content */} + </nav> +</header> + +<main id="main-content"> + {/* primary content */} +</main> + +<aside aria-label="Related articles"> + {/* sidebar content */} +</aside> + +<footer> + {/* footer content */} +</footer> + +{/* ✅ Multiple nav elements need unique labels */} +<nav aria-label="Primary"> + {/* primary navigation */} +</nav> +<nav aria-label="Breadcrumb"> + {/* breadcrumb navigation */} +</nav> +<nav aria-label="Footer"> + {/* footer navigation */} +</nav> +``` + +**Note:** The `<main>` element should have an ID for skip links to target (`id="main-content"`). + +### ARIA Labels (When Needed) + +Only use ARIA when semantic HTML isn't sufficient: + +```jsx +// ✅ Icon button needs aria-label +<button aria-label="Close menu"> + <XIcon /> +</button> + +// ✅ Input needs aria-describedby for error context +<input + type="email" + aria-describedby="email-error" + aria-invalid={hasError} +/> +<p id="email-error" role="alert"> + Please enter a valid email address +</p> +``` + +### Live Regions + +Announce dynamic content changes. Per [ARIA APG practices](https://www.w3.org/WAI/ARIA/apg/practices/live-regions/): + +```jsx +// ✅ Status updates are announced (polite = waits for idle) +<div aria-live="polite" aria-atomic="true"> + {statusMessage} +</div> + +// ✅ Errors are announced immediately (assertive = interrupts) +<form aria-describedby="form-errors"> + <div id="form-errors" role="alert" aria-live="assertive" /> +</form> + +// ✅ Loading states +<div aria-live="polite" aria-busy="true" aria-label="Loading content"> + <Spinner /> +</div> +``` + +**Note:** Always add `aria-atomic="true"` to ensure the entire region is announced, not just the changed portion. + +## Visual Accessibility + +### Color Contrast (4.5:1 Minimum) + +Ensure sufficient contrast for text: + +- **Normal text**: 4.5:1 ratio (AA standard) +- **Large text** (18px+ or 14px bold): 3:1 ratio (still criterion 1.4.3) +- **UI components**: 3:1 ratio against adjacent colors + +```jsx +// Use semantic color tokens from your design system +// that automatically meet contrast requirements +<button className="bg-blue-600 text-white"> + Submit +</button> +``` + +### Touch Targets + +Minimum 44x44 pixels for interactive elements: + +```jsx +// ✅ Large enough touch target +<button className="min-h-[44px] min-w-[44px]"> + <Icon className="w-5 h-5" /> +</button> + +// ❌ Too small - difficult to tap +<button className="w-4 h-4"> + <Icon /> +</button> +``` + +### Form Labels + +Associate labels with inputs for screen readers. Per [ARIA APG naming practices](https://www.w3.org/WAI/ARIA/apg/practices/names-and-descriptions/): + +```jsx +// ✅ Explicit association (preferred) +<label htmlFor="email">Email address</label> +<input id="email" type="email" /> + +// ✅ Implicit association (wrapped) +<label> + Email address + <input type="email" /> +</label> + +// ✅ Complex fields with aria-labelledby +<input aria-labelledby="name-label-hint" /> +<span id="name-label-hint">First and last name</span> + +// ✅ Form errors - use aria-describedby (better support than aria-errormessage) +<input + id="email" + type="email" + aria-describedby="email-error" + aria-invalid={hasError} +/> +{hasError && ( + <span id="email-error" role="alert" className="text-red-600"> + Please enter a valid email address + </span> +)} +``` + +### Color Independence + +Never convey information by color alone: + +```jsx +// ✅ Color + icon/text +<span className="text-red-600"> + <ErrorIcon /> Required field +</span> + +// ❌ Color only - not accessible +<span className="text-red-600"> + Required field +</span> +``` + +## Definition of Done Checklist + +Before marking accessibility work as complete, verify: + +- [ ] All interactive elements are keyboard accessible +- [ ] Focus indicators are visible on all focusable elements +- [ ] Skip link present and functional +- [ ] Color contrast meets 4.5:1 (text) or 3:1 (large text/components) +- [ ] Touch targets are minimum 44x44px +- [ ] No information conveyed by color alone +- [ ] `prefers-reduced-motion` respected in all animations +- [ ] ARIA labels used correctly (not overused) +- [ ] Semantic HTML used throughout +- [ ] Form inputs have associated labels +- [ ] Error messages are announced to screen readers +- [ ] Focus trapped in modals/dialogs +- [ ] No content flashes more than 3 times per second + +## Quick Reference + +| Requirement | WCAG Criterion | Minimum | +|-------------|----------------|---------| +| Color contrast (text) | 1.4.3 | 4.5:1 | +| Color contrast (large text) | 1.4.3 | 3:1 | +| Focus visible | 2.4.7 | Visible indicator | +| Skip link | 2.4.1 | Bypass blocks | +| Touch target | 2.5.5 | 44x44px | +| Motion | 2.3.3 | Respect preference | +| Error identification | 3.3.1 | Announced | + +### WCAG 2.2 New Additions + +| Requirement | WCAG Criterion | Description | +|-------------|----------------|-------------| +| Focus not obscured | 2.4.11 | Focused element fully visible | +| Redundant entry | 3.3.7 | Don't re-enter info already provided | + +## See Also + +- [REFERENCE.md](./REFERENCE.md) - Detailed WCAG checklists and deep-dive documentation + +## Sources + +- [ARIA Authoring Practices Guide (APG)](https://www.w3.org/WAI/ARIA/apg/) - Official W3C patterns for accessible widgets +- [WCAG 2.2 Quick Reference](https://www.w3.org/WAI/WCAG22/quickref/) - Accessibility guidelines +- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) - Contrast verification tool +- [A11y Project Checklist](https://www.a11yproject.com/checklist/) - Practical accessibility checklist