Accessibility (WCAG 2.2 AA)
Core Principles
-
Semantic HTML elements and ARIA landmarks
-
Keyboard-first navigation with visible focus states
-
Skip links for main content in layouts
-
Inclusive, people-first language
Semantic HTML
<!-- Use semantic elements --> <header> <nav aria-label="Main navigation"> <a href="/dashboard">Dashboard</a> <a href="/projects">Projects</a> </nav> </header>
<main id="main-content"> <h1>Page Title</h1> <section aria-labelledby="section-heading"> <h2 id="section-heading">Section Title</h2> <article>...</article> </section> </main>
<footer>...</footer>
Skip Links
<!-- At top of layout --> <a href="#main-content" class="sr-only focus:not-sr-only focus:absolute ..."> Skip to main content </a>
Form Accessibility
Label Every Control
<!-- Visible label --> <label for="email">Email address</label> <input id="email" type="email" />
<!-- Or using aria-label for icon-only inputs --> <input type="search" aria-label="Search events" />
Required Fields
<label for="name">Name <span aria-hidden="true">*</span></label> <input id="name" required aria-required="true" />
Error Messages
<input id="email" aria-invalid={hasError} aria-describedby={hasError ? 'email-error' : undefined} /> {#if hasError} <p id="email-error" class="text-destructive"> Please enter a valid email address </p> {/if}
Validation Behavior
-
On validation failure: focus first invalid input
-
Never disable submit just to block validation
-
Show inline errors linked via aria-describedby
Keyboard Navigation
Focus Order
<!-- Natural tab order follows DOM order --> <button>First</button> <button>Second</button> <button>Third</button>
<!-- Remove from tab order when hidden --> <div hidden> <button tabindex="-1">Hidden button</button> </div>
Focus Management in Dialogs
// When dialog opens, focus first interactive element $effect(() => { if (open) { dialogRef?.querySelector("input, button")?.focus(); } });
// When dialog closes, return focus to trigger const triggerRef = document.activeElement; // ... on close triggerRef?.focus();
Keyboard Shortcuts
<button onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }}
Action
</button>
Images and Icons
Informative Images
<img src={user.avatar} alt={Profile photo of ${user.name}} />
Decorative Images
<img src="/decorative-pattern.svg" alt="" aria-hidden="true" />
Icon Buttons
<button aria-label="Delete event"> <TrashIcon aria-hidden="true" /> </button>
Icons with Text
<button> <PlusIcon aria-hidden="true" /> Add Event </button>
ARIA Patterns
Live Regions
<!-- For dynamic updates (notifications, loading states) --> <div aria-live="polite" aria-atomic="true"> {#if loading} Loading events... {/if} </div>
<!-- For urgent messages --> <div role="alert"> Error: Failed to save changes </div>
Expandable Content
<button aria-expanded={isExpanded} aria-controls="panel-content"
{isExpanded ? 'Collapse' : 'Expand'}
</button> <div id="panel-content" hidden={!isExpanded}> Panel content </div>
Tabs
<div role="tablist" aria-label="Event tabs"> <button role="tab" aria-selected={activeTab === 'details'}> Details </button> <button role="tab" aria-selected={activeTab === 'stack'}> Stack Trace </button> </div> <div role="tabpanel" aria-labelledby="details-tab"> Tab content </div>
Color and Contrast
-
Minimum contrast ratio: 4.5:1 for normal text
-
3:1 for large text and UI components
-
Don't rely on color alone to convey information
<!-- ✅ Good: Icon + color + text --> <span class="text-destructive"> <AlertIcon aria-hidden="true" /> Error: Invalid input </span>
<!-- ❌ Bad: Color only --> <span class="text-destructive">Invalid input</span>
Testing Accessibility
Run axe-playwright audits in E2E tests
npm run test:e2e
// In Playwright tests import AxeBuilder from "@axe-core/playwright";
test("page is accessible", async ({ page }) => { await page.goto("/dashboard"); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); });