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:1Good 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-coreimport 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 simulatorCommon 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