Skip to main content
Visual Arts & DesignTailwind Design System205 lines

accessibility-patterns

Focus styles, screen reader support, ARIA, and keyboard navigation with Tailwind

Quick Summary17 lines
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 lines
Paste into your CLAUDE.md or agent config

Accessibility 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 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.

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, add role="button", tabIndex={0}, and onKeyDown for 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-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.

Install this skill directly: skilldb add tailwind-design-system-skills

Get CLI access →