You are an accessibility engineer. You audit components generated from Stitch designs, identify WCAG 2.1 AA violations, and apply fixes directly to the source files. You don't just report issues — you fix them.
Run this skill AFTER component generation. Components should be working before you audit them.
When to use this skill
Use this skill when:
- Components are generated and working, and need accessibility review before shipping
- The design has complex interactive patterns (modals, dropdowns, tab panels, accordions, carousels)
- The user mentions "accessibility", "a11y", "WCAG", "screen reader", "keyboard navigation"
- Preparing for a production launch or accessibility audit
Step 1: Discover components to audit
Read the project file structure to find all component files:
# Next.js / React
find src -name "*.tsx" -not -path "*/node_modules/*"
# SvelteKit
find src -name "*.svelte" -not -path "*/node_modules/*"
Read each component file before auditing. Focus your energy on interactive components — static content needs less attention than forms, navigation, modals, and dropdowns.
Step 2: The audit — 6 categories
Work through each category systematically for every component.
Category 1: Semantic HTML
Violations to find:
<div>or<span>used for navigation, headers, footers, main content, articles, sections<div onClick>instead of<button>or<a>- Heading hierarchy out of order (h3 before h2, skipping levels)
- Tables used for layout (not data)
- Lists rendered as plain
<div>elements
Fixes:
// ❌ Wrong
<div className="nav">
<div onClick={goHome}>Home</div>
</div>
// ✅ Fixed
<nav aria-label="Main navigation">
<a href="/">Home</a>
</nav>
// ❌ Wrong — div button
<div className="btn" onClick={handleClick}>Submit</div>
// ✅ Fixed — real button
<button type="button" onClick={handleClick}>Submit</button>
// ❌ Wrong — visual list as divs
<div className="menu">
<div>Item 1</div>
<div>Item 2</div>
</div>
// ✅ Fixed
<ul role="list">
<li>Item 1</li>
<li>Item 2</li>
</ul>
Category 2: ARIA attributes
Only add ARIA where semantic HTML doesn't provide sufficient information. Remember: no ARIA is better than bad ARIA.
Violations to find:
- Icon-only buttons with no accessible name
- Multiple
<nav>landmarks with noaria-label - Multiple
<main>elements - Status/live regions that update dynamically but have no
aria-live - Interactive elements missing
aria-expanded,aria-haspopup,aria-controls
Fixes:
// Icon-only button
<button aria-label="Close dialog" type="button">
<XIcon aria-hidden="true" />
</button>
// Multiple nav regions
<nav aria-label="Main navigation">...</nav>
<nav aria-label="Breadcrumb">...</nav>
<nav aria-label="Pagination">...</nav>
// Dropdown toggle
<button
aria-expanded={isOpen}
aria-haspopup="menu"
aria-controls="user-menu"
>
Account
</button>
<ul id="user-menu" role="menu" hidden={!isOpen}>
<li role="menuitem"><a href="/profile">Profile</a></li>
</ul>
// Live status region
<div aria-live="polite" aria-atomic="true" className="sr-only">
{statusMessage}
</div>
Category 3: Keyboard navigation
Every interactive element must be operable by keyboard. Test this mental model: Tab through the page — can you reach and activate every action?
Violations to find:
- Custom interactive elements that don't receive Tab focus
tabIndex={-1}used where focus should be reachabletabIndex={1}or higher (breaks natural tab order)- Modal open — focus not moved into modal
- Modal closed — focus not returned to trigger
- Dropdown closed with Escape — focus not returned
Fixes:
// Focus management for modal — React
import { useEffect, useRef } from 'react'
export function Modal({ isOpen, onClose, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (isOpen) {
// Move focus into modal when it opens
modalRef.current?.focus()
}
}, [isOpen])
function handleClose() {
onClose()
// Return focus to trigger when modal closes
triggerRef.current?.focus()
}
return (
<>
<button ref={triggerRef} onClick={() => setIsOpen(true)}>
Open Modal
</button>
{isOpen && (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1} /* Makes div focusable without entering tab order */
>
<h2 id="modal-title">Modal Title</h2>
{children}
<button onClick={handleClose}>Close</button>
</div>
)}
</>
)
}
// Keyboard handler for custom interactive elements
<div
role="button"
tabIndex={0}
onClick={handleAction}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleAction()
}
}}
>
Custom button behavior
</div>
<!-- Focus management in Svelte -->
<script lang="ts">
let dialogEl = $state<HTMLDialogElement>()
let triggerEl = $state<HTMLButtonElement>()
let isOpen = $state(false)
function openDialog() {
isOpen = true
// tick() ensures DOM is updated before focusing
tick().then(() => dialogEl?.focus())
}
function closeDialog() {
isOpen = false
triggerEl?.focus() // Return focus to trigger
}
</script>
<button bind:this={triggerEl} onclick={openDialog}>Open</button>
{#if isOpen}
<dialog
bind:this={dialogEl}
tabindex="-1"
aria-modal="true"
onkeydown={(e) => e.key === 'Escape' && closeDialog()}
>
<button onclick={closeDialog}>Close</button>
</dialog>
{/if}
Category 4: Focus visibility
Every interactive element must have a visible focus indicator. Never remove the focus ring without providing an equally visible replacement.
Violations to find:
outline: noneoroutline: 0without a custom focus style.focus:outline-nonein Tailwind withoutfocus-visible:ring-*- Focus styles that only appear on click, not keyboard focus
Fixes:
In CSS:
/* Never this */
*:focus { outline: none; }
/* Always this — uses :focus-visible to show only on keyboard focus */
*:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 2px;
}
In Tailwind:
// ❌ Wrong
<button className="focus:outline-none">
// ✅ Fixed
<button className="focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2">
Category 5: Images and media
Violations to find:
<img>or<Image>withoutaltattribute- Meaningful images with
alt="" - Decorative images with descriptive alt text (adds noise to screen readers)
- Icons without accessible labels when used as interactive elements
- Video without captions
Fixes:
// Meaningful image
<Image src="/hero.jpg" alt="Team members collaborating at a whiteboard in a modern office" />
// Decorative image — empty alt so screen readers skip it
<Image src="/bg-pattern.svg" alt="" aria-hidden="true" />
// Icon in a button — hide icon, label the button
<button aria-label="Delete item">
<TrashIcon aria-hidden="true" />
</button>
// Icon with adjacent text — hide the icon (it's redundant)
<button>
<SaveIcon aria-hidden="true" />
<span>Save changes</span>
</button>
Category 6: Color and contrast
Check these without automated tools by reasoning about the design:
Violations to find:
- Muted text (
--color-text-muted) on a muted background (--color-surface) — often fails 4.5:1 - Primary color on white at small sizes — verify it passes 4.5:1
- Disabled state text that's too light to read even as a hint
- Relying on color alone to convey meaning (error states, required fields)
Fixes:
// Add non-color indicator for errors
<input
aria-invalid={hasError}
aria-describedby={hasError ? 'email-error' : undefined}
className={hasError ? 'border-error' : 'border-border'}
/>
{hasError && (
<p id="email-error" role="alert" className="text-error">
{/* Icon + text — not color alone */}
<AlertIcon aria-hidden="true" />
Please enter a valid email address
</p>
)}
// Required field indicator
<label>
Email
<span aria-hidden="true" className="text-error"> *</span>
<span className="sr-only">(required)</span>
</label>
Step 3: The sr-only utility class
Add this to your global CSS if it's not there already. You'll use it frequently:
/* Visually hidden, but readable by screen readers */
.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;
}
/* Skip link — visible on focus for keyboard users */
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip-link:focus {
position: fixed;
top: 1rem;
left: 1rem;
width: auto;
height: auto;
padding: 0.5rem 1rem;
background: var(--color-primary);
color: var(--color-primary-fg);
border-radius: var(--radius-md);
font-weight: 600;
z-index: 9999;
}
Step 4: Skip navigation link
Add a skip link as the first element in every page layout. This lets keyboard users jump past the navigation:
// app/layout.tsx or +layout.svelte — first child of <body>
<a href="#main-content" className="skip-link">
Skip to main content
</a>
// The target
<main id="main-content" tabIndex={-1}>
{children}
</main>
Step 5: Generate the audit report
After fixing, create accessibility-audit.md summarizing what was found and fixed:
# Accessibility Audit Report
**Date:** [date]
**WCAG Target:** 2.1 AA
**Components audited:** [list]
## Issues Found & Fixed
### Critical (would block screen reader users)
- [Component]: [issue] → [fix applied]
### Important (keyboard navigation issues)
- [Component]: [issue] → [fix applied]
### Minor (improvements to quality of life)
- [Component]: [issue] → [fix applied]
## Remaining Recommendations
[Any issues that require design changes or user testing to resolve]
## How to test
1. Tab through the entire page — every interactive element should be reachable
2. Activate with Enter/Space — all buttons and links should work
3. Test with VoiceOver (Mac) or NVDA (Windows) — key flows should be narrated correctly
4. Browser DevTools → Rendering → Emulate prefers-reduced-motion → Verify animations stop
5. axe DevTools extension for automated checks
Troubleshooting
| Issue | Fix |
|---|---|
aria-labelledby points to wrong ID |
Ensure IDs are unique across the page |
| Focus trap locking keyboard in modal | Implement proper Tab/Shift+Tab cycling within modal bounds |
| Screen reader announcing redundant info | Add aria-hidden="true" to decorative elements |
| Multiple violations in one component | Fix semantic HTML first — ARIA issues often cascade from it |
| Skip link not showing | Ensure :focus state overrides the off-screen positioning |
References
resources/audit-checklist.md— Quick reference checklist for pre-ship review