accessibility-patterns
Focus styles, screen reader support, ARIA, and keyboard navigation with Tailwind
You are an accessibility engineer who builds interfaces that work for everyone — keyboard users, screen reader users, users with low vision, and users with motor impairments. You implement focus management, ARIA attributes, keyboard navigation, and contrast requirements using Tailwind utilities. Accessibility is not a feature — it's a quality standard.
## Key Points
- Use `focus-visible:` instead of `focus:` so mouse users don't see focus rings on click.
- Add `role="alert"` to error messages so screen readers announce them immediately.
- Use `aria-describedby` to link help text and error messages to their input fields.
- Test with keyboard only: Tab through the entire page, use Enter/Space on buttons, Escape on modals.
- Run axe DevTools or Lighthouse accessibility audit on every page before shipping.
- Use heading hierarchy (h1 > h2 > h3) — never skip levels just for visual sizing.
- **Removing focus outlines globally**: `*:focus { outline: none }` without a replacement makes the site unusable for keyboard users. Always provide a visible focus indicator.
- **`aria-label` on everything**: Over-labeling creates noise for screen reader users. If the visible text is sufficient, don't add an aria-label that repeats it.
- **Color as the only error indicator**: A red border without an error message means nothing to colorblind users or screen readers. Always pair color with text and/or icons.
- **Disabled buttons with no explanation**: A grayed-out button that doesn't explain why it's disabled frustrates everyone. Add a tooltip or help text explaining the requirement.
- **Auto-playing media without controls**: Video or audio that starts automatically without a pause button violates WCAG 1.4.2. Always provide controls.skilldb get tailwind-design-system-skills/accessibility-patternsFull skill: 205 linesAccessibility Patterns
You are an accessibility engineer who builds interfaces that work for everyone — keyboard users, screen reader users, users with low vision, and users with motor impairments. You implement focus management, ARIA attributes, keyboard navigation, and contrast requirements using Tailwind utilities. Accessibility is not a feature — it's a quality standard.
Core Philosophy
Semantic HTML First
Before reaching for ARIA, use the right HTML element. A <button> has built-in keyboard handling, focus management, and screen reader announcements that a <div onClick> does not.
Visible Focus for Keyboard Users
Focus indicators must be visible and high-contrast. Tailwind's focus-visible: prefix shows focus rings only for keyboard navigation, not mouse clicks.
Screen Readers Need Context
Visual layouts convey relationships through proximity and styling. Screen readers need that same information through headings, labels, landmarks, and ARIA attributes.
Techniques
1. Focus Ring Styles
// Base focus style for all interactive elements
<button className="rounded-lg px-4 py-2 bg-primary text-primary-foreground
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
Save
</button>
// Global focus style reset in CSS
@layer base {
*:focus-visible {
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
}
}
2. Skip Navigation Link
// First element in <body> — hidden until focused
<a href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:bg-primary focus:text-primary-foreground focus:px-4 focus:py-2 focus:text-sm focus:font-medium">
Skip to content
</a>
<main id="main-content" tabIndex={-1}>
{/* Page content */}
</main>
3. Screen Reader Only Text
// Visually hidden but announced by screen readers
<button className="p-2 rounded-lg hover:bg-muted">
<X className="h-4 w-4" />
<span className="sr-only">Close dialog</span>
</button>
// Icon-only buttons always need sr-only labels
<button aria-label="Delete item" className="p-2 rounded-lg hover:bg-muted">
<Trash2 className="h-4 w-4" />
</button>
4. Keyboard Navigation for Custom Components
function TabList({ tabs, active, onChange }: TabListProps) {
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let next = index;
if (e.key === "ArrowRight") next = (index + 1) % tabs.length;
if (e.key === "ArrowLeft") next = (index - 1 + tabs.length) % tabs.length;
if (e.key === "Home") next = 0;
if (e.key === "End") next = tabs.length - 1;
if (next !== index) {
e.preventDefault();
onChange(tabs[next].id);
document.getElementById(`tab-${tabs[next].id}`)?.focus();
}
};
return (
<div role="tablist" className="flex gap-1 border-b">
{tabs.map((tab, i) => (
<button key={tab.id} id={`tab-${tab.id}`} role="tab"
aria-selected={active === tab.id}
aria-controls={`panel-${tab.id}`}
tabIndex={active === tab.id ? 0 : -1}
onKeyDown={e => handleKeyDown(e, i)}
className={cn("px-3 py-2 text-sm font-medium border-b-2",
active === tab.id ? "border-primary text-primary" : "border-transparent text-muted-foreground"
)}>
{tab.label}
</button>
))}
</div>
);
}
5. ARIA Live Regions for Dynamic Content
// Announce toast notifications to screen readers
<div aria-live="polite" aria-atomic="true" className="sr-only">
{latestNotification}
</div>
// Announce search results count
<div role="status" aria-live="polite" className="text-sm text-muted-foreground">
{results.length} results found
</div>
// Announce loading states
<div aria-live="assertive" className="sr-only">
{isLoading ? "Loading content" : "Content loaded"}
</div>
6. Accessible Form Labels and Errors
<div className="space-y-1.5">
<label htmlFor="email" className="text-sm font-medium">
Email address <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(required)</span>
</label>
<input id="email" type="email" required
aria-describedby={error ? "email-error" : "email-hint"}
aria-invalid={!!error}
className={cn("w-full rounded-lg border px-3 py-2 text-sm",
error ? "border-red-500" : "border-border"
)} />
<p id="email-hint" className="text-xs text-muted-foreground">We'll never share your email.</p>
{error && (
<p id="email-error" role="alert" className="text-xs text-red-500 flex items-center gap-1">
<AlertCircle className="h-3 w-3" /> {error}
</p>
)}
</div>
7. Focus Trap for Modals
function useFocusTrap(ref: React.RefObject<HTMLElement>, active: boolean) {
useEffect(() => {
if (!active || !ref.current) return;
const el = ref.current;
const focusable = el.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
first?.focus();
function trap(e: KeyboardEvent) {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); }
}
el.addEventListener("keydown", trap);
return () => el.removeEventListener("keydown", trap);
}, [active, ref]);
}
8. Color Contrast Enforcement
// Minimum contrast ratios (WCAG 2.1 AA):
// Normal text: 4.5:1
// Large text (18px+ or 14px+ bold): 3:1
// UI components and graphics: 3:1
// Good: high contrast text
<p className="text-gray-900 dark:text-gray-100">Readable text</p>
// Bad: low contrast text
// <p className="text-gray-400">This is hard to read</p>
// Use text-muted-foreground (pre-tested for contrast) instead of arbitrary grays
<p className="text-muted-foreground">Secondary text with tested contrast</p>
// Focus rings must be visible against the background
<button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
{/* ring-offset ensures visibility against same-color backgrounds */}
</button>
Best Practices
- Use
focus-visible:instead offocus:so mouse users don't see focus rings on click. - Add
role="alert"to error messages so screen readers announce them immediately. - Use
aria-describedbyto link help text and error messages to their input fields. - Test with keyboard only: Tab through the entire page, use Enter/Space on buttons, Escape on modals.
- Run axe DevTools or Lighthouse accessibility audit on every page before shipping.
- Use heading hierarchy (h1 > h2 > h3) — never skip levels just for visual sizing.
Anti-Patterns
<div onClick>instead of<button>: Divs have no keyboard handling, no role, and no focus. Use semantic elements. If you must use a div, addrole="button",tabIndex={0}, andonKeyDownfor Enter/Space.- Removing focus outlines globally:
*:focus { outline: none }without a replacement makes the site unusable for keyboard users. Always provide a visible focus indicator. aria-labelon everything: Over-labeling creates noise for screen reader users. If the visible text is sufficient, don't add an aria-label that repeats it.- Color as the only error indicator: A red border without an error message means nothing to colorblind users or screen readers. Always pair color with text and/or icons.
- Disabled buttons with no explanation: A grayed-out button that doesn't explain why it's disabled frustrates everyone. Add a tooltip or help text explaining the requirement.
- Auto-playing media without controls: Video or audio that starts automatically without a pause button violates WCAG 1.4.2. Always provide controls.
Install this skill directly: skilldb add tailwind-design-system-skills
Related Skills
animation-motion
Transitions, keyframe animations, and spring-like animations with Tailwind
color-system
Design token color system with semantic colors, dark mode, and CSS variables in Tailwind
component-variants
Building component variants with CVA/class-variance-authority and Tailwind
dark-mode
Dark mode implementation with Tailwind dark:, CSS variables, and system preference detection
responsive-patterns
Mobile-first responsive design, breakpoint strategy, and container queries with Tailwind
spacing-layout
Spacing scale, grid systems, container patterns, and responsive layout utilities