HomeCSS Tailwind
Chapter 8

08 — Accessibility (a11y)

Semantic HTML

<!-- Use correct elements -->
<button>Click me</button>                    <!-- ✅ -->
<div onclick="handleClick">Click me</div>    <!-- ❌ -->
 
<!-- Landmark elements -->
<header>Site header</header>
<nav>Navigation</nav>
<main>Main content</main>
<aside>Sidebar</aside>
<footer>Footer</footer>
<article>Blog post</article>
<section>Section</section>

Headings Hierarchy

<h1>Page Title</h1>          <!-- One per page -->
<h2>Section</h2>
  <h3>Subsection</h3>
  <h3>Subsection</h3>
<h2>Another Section</h2>
  <h3>Subsection</h3>

Never skip levels: h1 → h3 is wrong (missing h2)


ARIA Attributes

Roles

<div role="navigation">...</div>
<div role="main">...</div>
<div role="alert">Error message</div>
<div role="dialog">Modal content</div>

States & Properties

<button aria-expanded="false">Menu</button>
<div aria-hidden="true">Hidden from screen readers</div>
<input aria-required="true">
<div aria-live="polite">Updates announced</div>
<div aria-live="assertive">Urgent updates</div>

Labels

<!-- Visible label -->
<label for="email">Email</label>
<input id="email" type="email">
 
<!-- Hidden label (screen readers only) -->
<label for="search" class="sr-only">Search</label>
<input id="search" type="search">
 
<!-- aria-label when no visible label -->
<button aria-label="Close modal"></button>
 
<!-- aria-labelledby -->
<h2 id="modal-title">Delete Item</h2>
<div role="dialog" aria-labelledby="modal-title">
  Are you sure?
</div>

Described By

<input id="email" aria-describedby="email-error">
<p id="email-error" class="text-red-600">Invalid email format</p>

Focus Management

Visible Focus

/* Never remove outline without replacement */
button:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}
 
/* Tailwind: focus-visible for keyboard users */
<button class="focus-visible:ring-2 focus-visible:ring-blue-500">
  Click
</button>

Skip Links

<a href="#main" class="skip-link">Skip to main content</a>
 
<header>...</header>
<main id="main" tabindex="-1">...</main>
 
<style>
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: #fff;
  padding: 8px;
  z-index: 100;
}
.skip-link:focus {
  top: 0;
}
</style>

Focus Trap (Modal)

function trapFocus(element: HTMLElement) {
  const focusable = element.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0] as HTMLElement;
  const last = focusable[focusable.length - 1] as HTMLElement;
  
  element.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;
    
    if (e.shiftKey) {
      if (document.activeElement === first) {
        e.preventDefault();
        last.focus();
      }
    } else {
      if (document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  });
}

Return Focus

function openModal() {
  const trigger = document.activeElement as HTMLElement;
  
  // Open modal...
  
  // On close
  trigger.focus();
}

Screen Reader Only Content

/* Tailwind: sr-only */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
<button>
  <span class="sr-only">Close menu</span>
  <svg>...</svg>
</button>
 
<!-- Not hidden visually -->
<span class="not-sr-only">Visible text</span>

Images

<!-- Informative images -->
<img src="chart.png" alt="Bar chart showing 2023 sales: Q1 $10k, Q2 $15k, Q3 $12k">
 
<!-- Decorative images -->
<img src="divider.png" alt="" aria-hidden="true">
 
<!-- Complex images -->
<figure>
  <img src="infographic.png" alt="Company organizational chart">
  <figcaption>Fig 1: Org chart showing reporting structure</figcaption>
</figure>

Forms

Labels

<!-- Explicit -->
<label for="name">Full Name</label>
<input id="name" type="text">
 
<!-- Implicit -->
<label>
  Email
  <input type="email">
</label>
 
<!-- Hidden but accessible -->
<label for="search" class="sr-only">Search</label>
<input id="search" type="search" placeholder="Search...">

Error Messages

<div>
  <label for="email">Email</label>
  <input 
    id="email" 
    type="email" 
    aria-invalid="true"
    aria-describedby="email-error"
  >
  <p id="email-error" class="error">
    Please enter a valid email address
  </p>
</div>

Required Fields

<label for="password">
  Password <span aria-label="required">*</span>
</label>
<input id="password" type="password" aria-required="true">

Keyboard Navigation

Tab Order

<!-- Logical DOM order = tab order -->
<button>First</button>
<button>Second</button>
<button>Third</button>
 
<!-- tabindex -->
<button tabindex="0">Focusable in sequence</button>
<button tabindex="-1">Not focusable by tab</button>
<button tabindex="1">Avoid - breaks natural order</button>

Interactive Elements

<!-- Use buttons for actions -->
<button onclick="submit()">Submit</button>
 
<!-- Use links for navigation -->
<a href="/next">Next page</a>
 
<!-- Custom widgets need keyboard support -->
<div 
  role="button"
  tabindex="0"
  onclick="handleClick()"
  onkeydown="handleKeydown(event)"
>
  Custom button
</div>

Key Handlers

function handleKeydown(e: KeyboardEvent) {
  switch(e.key) {
    case 'Enter':
    case ' ':
      e.preventDefault();
      handleClick();
      break;
    case 'Escape':
      closeModal();
      break;
    case 'ArrowUp':
    case 'ArrowDown':
      navigateMenu(e);
      break;
  }
}

Color Contrast

Minimum ratios (WCAG 2.1):
- Normal text: 4.5:1
- Large text (18pt+): 3:1
- UI components: 3:1

Good Contrast

<!-- Black on white: 21:1 -->
<div class="text-black bg-white">High contrast</div>
 
<!-- White on dark blue: 8.6:1 -->
<div class="text-white bg-blue-800">Good contrast</div>

Poor Contrast (Avoid)

<!-- Gray on white: 1.6:1 -->
<div class="text-slate-400 bg-white">Poor contrast ❌</div>
 
<!-- Light gray on white: 1.2:1 -->
<div class="text-slate-200 bg-white">Very poor ❌</div>

Tools: Chrome DevTools Lighthouse, WebAIM Contrast Checker


Dynamic Content

Live Regions

<!-- Announce updates -->
<div aria-live="polite">
  <!-- Screen readers announce when this changes -->
  <p>3 items in cart</p>
</div>
 
<!-- Urgent updates -->
<div aria-live="assertive" aria-atomic="true">
  <!-- Announced immediately -->
  <p>Error: Form submission failed</p>
</div>

Status Messages

function showMessage(message: string, type: 'success' | 'error') {
  const status = document.getElementById('status');
  status.textContent = message;
  status.setAttribute('role', 'alert');
  status.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
}

Testing Accessibility

Automated

# Lighthouse
npm run lighthouse
 
# Axe
npm install axe-core
import axe from 'axe-core';
 
axe.run(document, (err, results) => {
  console.log(results.violations);
});

Manual

1. Keyboard only navigation (Tab, Shift+Tab, Enter, Space)
2. Screen reader (NVDA, VoiceOver, JAWS)
3. Zoom to 200%
4. High contrast mode
5. Color blindness simulator

Common Patterns

Accessible Modal

<div 
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
  tabindex="-1"
>
  <h2 id="modal-title">Delete Item</h2>
  <p>Are you sure you want to delete this item?</p>
  <button>Cancel</button>
  <button>Delete</button>
</div>

Accessible Dropdown

<div class="dropdown">
  <button 
    aria-haspopup="true"
    aria-expanded="false"
    onclick="toggleDropdown()"
  >
    Options
  </button>
  <ul 
    role="menu" 
    aria-hidden="true"
  >
    <li role="menuitem">Edit</li>
    <li role="menuitem">Delete</li>
  </ul>
</div>

Accessible Accordion

<div>
  <button 
    aria-expanded="false"
    aria-controls="panel-1"
  >
    Section 1
  </button>
  <div 
    id="panel-1" 
    role="region"
    aria-labelledby="btn-1"
    hidden
  >
    Content
  </div>
</div>

Key Takeaways

  • Semantic HTML: Use correct elements (button, nav, main, etc.)
  • ARIA: Only when HTML isn't enough (aria-label, aria-describedby)
  • Focus: Visible focus, logical tab order, trap in modals
  • Labels: Every input needs a label (visible or sr-only)
  • Contrast: Minimum 4.5:1 for text, 3:1 for UI components
  • Keyboard: All functionality must work without a mouse
  • Screen readers: Test with NVDA/VoiceOver
  • Dynamic content: Use aria-live regions for updates