.claude/skills/accessibility/SKILL.md
Accessibility (a11y) patterns for this Next.js application. Covers WCAG 2.1 AA compliance, ARIA attributes, keyboard navigation, focus management, and screen reader support. Use this skill when implementing accessible UI components or validating accessibility requirements.
npx skillsauth add NextSpark-js/nextspark accessibilityInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Patterns for implementing WCAG 2.1 Level AA compliant components with proper ARIA attributes, keyboard navigation, and screen reader support.
ACCESSIBILITY LAYERS:
Semantic HTML Foundation:
├── <button> for buttons (not <div>)
├── <nav> for navigation regions
├── <form> and <fieldset> for forms
├── <a> for links
└── Heading hierarchy (h1-h6)
ARIA Enhancement:
├── Landmark roles (navigation, main, complementary)
├── State attributes (aria-expanded, aria-current)
├── Relationship attributes (aria-labelledby, aria-describedby)
└── Live regions (aria-live, role="alert")
Visual Accessibility:
├── Color contrast (4.5:1 minimum)
├── Focus indicators (focus-visible:ring-2)
├── Motion reduction (prefers-reduced-motion)
└── Text scaling support
Keyboard Accessibility:
├── Tab order management
├── Focus trapping in modals
├── Escape key handling
└── Arrow key navigation (where appropriate)
| Criterion | Requirement | Implementation |
|-----------|-------------|----------------|
| 1.4.3 Color Contrast | 4.5:1 for normal text, 3:1 for large text | Use semantic color tokens |
| 1.4.11 Non-text Contrast | 3:1 for UI components | Focus rings, borders |
| 2.1.1 Keyboard | All functionality via keyboard | Tab navigation, Enter/Space |
| 2.4.3 Focus Order | Logical focus sequence | DOM order, tabIndex |
| 2.4.7 Focus Visible | Visible focus indicator | focus-visible:ring-2 |
| 4.1.2 Name, Role, Value | Accessible names for elements | Labels, aria-label |
// Navigation with accessible label
<nav role="navigation" aria-label="pagination">
<ul>
<li><a href="/page/1">1</a></li>
<li><a href="/page/2" aria-current="page">2</a></li>
</ul>
</nav>
// Breadcrumb navigation
<nav aria-label="breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li aria-current="page">Products</li>
</ol>
</nav>
// Main content area
<main role="main" id="main-content">
{/* Page content */}
</main>
// Accordion/Collapsible
<button
aria-expanded={isOpen}
aria-controls={`content-${id}`}
onClick={() => setIsOpen(!isOpen)}
>
Section Title
</button>
<div
id={`content-${id}`}
aria-hidden={!isOpen}
hidden={!isOpen}
>
{/* Collapsible content */}
</div>
// Tabs
<div role="tablist" aria-label="Settings tabs">
<button
role="tab"
aria-selected={activeTab === 'general'}
aria-controls="panel-general"
tabIndex={activeTab === 'general' ? 0 : -1}
>
General
</button>
</div>
<div
role="tabpanel"
id="panel-general"
aria-labelledby="tab-general"
hidden={activeTab !== 'general'}
>
{/* Tab content */}
</div>
// Menu/Dropdown
<button
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls="menu-items"
>
Options
</button>
<div
id="menu-items"
role="menu"
aria-hidden={!isOpen}
>
<button role="menuitem">Edit</button>
<button role="menuitem">Delete</button>
</div>
// core/components/ui/form.tsx pattern
<FormItem>
<FormLabel htmlFor={formItemId}>
Email
</FormLabel>
<FormControl>
<Input
id={formItemId}
aria-describedby={
!error
? formDescriptionId
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
aria-required={required}
/>
</FormControl>
<FormDescription id={formDescriptionId}>
We'll never share your email.
</FormDescription>
{error && (
<FormMessage id={formMessageId} role="alert">
{error.message}
</FormMessage>
)}
</FormItem>
// Status announcements (polite)
<div aria-live="polite" aria-atomic="true" className="sr-only">
{statusMessage}
</div>
// Error alerts (assertive)
<div role="alert" className="text-destructive">
{errorMessage}
</div>
// Toast notifications
<div
role="status"
aria-live="polite"
aria-atomic="true"
>
{notification}
</div>
All interactive elements use the standardized focus ring:
// Button focus pattern
<button
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Click me
</button>
// Input focus pattern
<input
className="focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
// Custom focus indicator
<div
tabIndex={0}
className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleAction()
}
}}
>
Interactive element
</div>
// Logical tab order (follows DOM)
<header>
<nav tabIndex={0}>...</nav> {/* First */}
</header>
<main tabIndex={0}>...</main> {/* Second */}
<aside tabIndex={0}>...</aside> {/* Third */}
// Remove from tab order
<div tabIndex={-1}>
{/* Programmatically focusable but not in tab order */}
</div>
// Skip link pattern
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-background focus:px-4 focus:py-2"
>
Skip to main content
</a>
Radix UI handles focus trapping automatically in dialogs:
// core/components/ui/dialog.tsx
import * as DialogPrimitive from '@radix-ui/react-dialog'
// Radix Dialog automatically:
// - Traps focus within modal
// - Returns focus on close
// - Handles Escape key
// - Manages aria-hidden on background
<DialogPrimitive.Root>
<DialogPrimitive.Trigger>Open</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay />
<DialogPrimitive.Content>
{/* Focus is trapped here */}
<DialogPrimitive.Close>
<span className="sr-only">Close</span>
<X aria-hidden="true" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
// Visually hidden but announced by screen readers
<span className="sr-only">Close dialog</span>
// Icon button with accessible name
<Button variant="ghost" size="icon">
<X aria-hidden="true" />
<span className="sr-only">Close</span>
</Button>
// More pages indicator
<span aria-hidden="true">...</span>
<span className="sr-only">More pages</span>
// Hide decorative icons from screen readers
<ChevronRight aria-hidden="true" className="h-4 w-4" />
// Separator in breadcrumb
<span role="presentation" aria-hidden="true">/</span>
// Loading spinner
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin"
/>
<span className="sr-only">Loading...</span>
// 1. Visible text (preferred)
<button>Delete Item</button>
// 2. aria-label for icon-only buttons
<button aria-label="Delete item">
<TrashIcon aria-hidden="true" />
</button>
// 3. aria-labelledby for complex labels
<div id="dialog-title">Confirm Deletion</div>
<div aria-labelledby="dialog-title">
{/* Content described by title */}
</div>
// 4. Form labels
<Label htmlFor="email">Email address</Label>
<Input id="email" type="email" />
The theme uses OKLCH color space for perceptually uniform colors:
/* contents/themes/default/styles/globals.css */
:root {
/* High contrast pairs */
--primary: oklch(0.2050 0 0); /* Dark */
--primary-foreground: oklch(0.9850 0 0); /* Light - 4.5:1+ contrast */
--destructive: oklch(0.5770 0.2450 27.3250);
--destructive-foreground: oklch(1 0 0);
/* Focus ring with sufficient contrast */
--ring: oklch(0.7080 0 0);
}
.dark {
/* Inverted for dark mode */
--primary: oklch(0.9220 0 0);
--primary-foreground: oklch(0.2050 0 0);
}
// ✅ CORRECT - Use semantic tokens that guarantee contrast
<p className="text-foreground">Primary text</p>
<p className="text-muted-foreground">Secondary text</p>
<button className="bg-primary text-primary-foreground">
Action
</button>
// ❌ WRONG - Arbitrary colors may not have proper contrast
<p className="text-gray-400">Low contrast text</p>
<button className="bg-blue-300 text-blue-100">
Poor contrast
</button>
// core/components/ui/double-range.tsx
<div
ref={thumbMinRef}
role="slider"
aria-label="Minimum value"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={localValue[0]}
tabIndex={disabled ? -1 : 0}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onKeyDown={handleKeyDown} // Arrow keys to adjust
/>
// core/components/ui/pagination.tsx
<nav role="navigation" aria-label="pagination">
<PaginationPrevious aria-label="Go to previous page" />
{pages.map((page) => (
<PaginationLink
aria-current={page === current ? 'page' : undefined}
>
{page}
</PaginationLink>
))}
<PaginationNext aria-label="Go to next page" />
</nav>
// core/components/ui/alert.tsx
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
eslint-plugin-jsx-a11y for lintingjest-axe for unit testingcypress-axe for E2E testingimport { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
test('should have no accessibility violations', async () => {
const { container } = render(<MyComponent />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
Keyboard Navigation:
Screen Reader:
Visual:
// ❌ NEVER: Div as button
<div onClick={handleClick}>Click me</div>
// ✅ CORRECT: Use semantic button
<button onClick={handleClick}>Click me</button>
// ❌ NEVER: Icon-only button without accessible name
<button><TrashIcon /></button>
// ✅ CORRECT: Add aria-label or sr-only text
<button aria-label="Delete item">
<TrashIcon aria-hidden="true" />
</button>
// ❌ NEVER: Remove focus indicator completely
<button className="outline-none focus:outline-none">
No focus indicator
</button>
// ✅ CORRECT: Custom focus indicator
<button className="focus-visible:ring-2 focus-visible:ring-ring">
Visible focus
</button>
// ❌ NEVER: Low contrast text
<p className="text-gray-300 bg-white">Hard to read</p>
// ✅ CORRECT: Use semantic tokens
<p className="text-muted-foreground bg-background">Readable</p>
// ❌ NEVER: Missing form labels
<input type="email" placeholder="Email" />
// ✅ CORRECT: Proper label association
<Label htmlFor="email">Email</Label>
<input id="email" type="email" />
// ❌ NEVER: Images without alt text
<img src="/logo.png" />
// ✅ CORRECT: Descriptive alt or empty for decorative
<img src="/logo.png" alt="Company Logo" />
<img src="/decorative.png" alt="" aria-hidden="true" />
// ❌ NEVER: Auto-playing media without controls
<video autoPlay src="/video.mp4" />
// ✅ CORRECT: User controls and captions
<video controls>
<source src="/video.mp4" />
<track kind="captions" src="/captions.vtt" />
</video>
Before finalizing component accessibility:
shadcn-components - Accessible UI component patternsreact-patterns - Component architecturetailwind-theming - Color contrast tokenscypress-e2e - Accessibility testingdevelopment
Zod validation patterns for this Next.js application. Covers schema definition, API validation, form integration, error formatting, and type inference. Use this skill when implementing validation for APIs, forms, or entity schemas.
development
Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
testing
Test coverage metrics and registry system for this Next.js application. Covers FEATURE_REGISTRY, FLOW_REGISTRY, TAGS_REGISTRY, and coverage metrics interpretation. Use this skill when evaluating test coverage, identifying gaps, or planning testing priorities.
development
TanStack Query (React Query) patterns for data fetching in this Next.js application. Covers useQuery, useMutation, optimistic updates, cache invalidation, and anti-patterns. Use this skill when implementing data fetching or state management with server data.