HTML Accessibility: ARIA & a11y
Accessibility (a11y) ensures your web content can be used by everyone, including people with disabilities. The first rule of ARIA: don't use ARIA if native HTML can do the job.
Why Accessibility Matters
~15% of the world's population has some form of disability — that's over 1 billion people
Legal requirements: ADA (US), EAA (EU), AODA (Canada), WCAG 2.1 AA is the typical standard
Better UX for everyone: keyboard navigation, captions, sufficient contrast help all users
SEO benefit: semantic HTML and ARIA help search engines understand content
Test with: VoiceOver (macOS/iOS), NVDA (Windows), axe DevTools, Lighthouse, keyboard-only browsing
ARIA Basics
<!-- ARIA roles, states, and properties supplement or override native semantics -->
<!-- role — what the element is -->
<div role="button" tabindex="0">Click me</div>
<!-- Better: just use <button> — has role, keyboard support, and semantics built-in -->
<!-- aria-label — name for elements without visible text -->
<button aria-label="Close dialog">✕</button>
<input type="search" aria-label="Search products">
<nav aria-label="Main navigation">...</nav>
<nav aria-label="Footer navigation">...</nav>
<!-- aria-labelledby — associate with another element's text -->
<h2 id="section-title">User Settings</h2>
<section aria-labelledby="section-title">...</section>
<!-- aria-describedby — additional description (announced after label) -->
<input type="password" id="pwd" aria-describedby="pwd-hint">
<p id="pwd-hint">Must be at least 8 characters with one number.</p>
<!-- aria-hidden — hide from accessibility tree (decorative elements) -->
<span aria-hidden="true">👋</span>
<i class="icon-star" aria-hidden="true"></i>
<!-- aria-live — announce dynamic content changes -->
<div aria-live="polite"> <!-- announce after current speech -->
Loading results...
</div>
<div role="alert" aria-live="assertive"> <!-- interrupt immediately -->
Error: Form submission failed.
</div>Interactive ARIA Patterns
<!-- Custom dropdown/select -->
<button aria-haspopup="listbox" aria-expanded="false" aria-controls="dropdown-list" id="dropdown-btn">
Select option
</button>
<ul role="listbox" id="dropdown-list" aria-labelledby="dropdown-btn" hidden>
<li role="option" aria-selected="false">Option 1</li>
<li role="option" aria-selected="true">Option 2</li>
</ul>
<!-- Tabs -->
<div role="tablist" aria-label="Settings tabs">
<button role="tab" aria-selected="true" aria-controls="tab-general" id="tab-general-btn">General</button>
<button role="tab" aria-selected="false" aria-controls="tab-security" id="tab-security-btn" tabindex="-1">Security</button>
</div>
<div role="tabpanel" id="tab-general" aria-labelledby="tab-general-btn">...</div>
<div role="tabpanel" id="tab-security" aria-labelledby="tab-security-btn" hidden>...</div>
<!-- Dialog/Modal -->
<div role="dialog" aria-modal="true" aria-labelledby="modal-title" aria-describedby="modal-desc">
<h2 id="modal-title">Confirm Deletion</h2>
<p id="modal-desc">This action cannot be undone. Are you sure?</p>
<button>Cancel</button>
<button>Delete</button>
</div>
<!-- Toggle button -->
<button aria-pressed="false">Mute</button>
<button aria-expanded="false" aria-controls="sidebar">Menu</button>Keyboard Navigation
// Trap focus inside modal while it's open
function trapFocus(element) {
const focusable = element.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
element.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) {
last.focus();
e.preventDefault();
}
} else {
if (document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
});
}
// Close modal with Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
// Arrow key navigation for tab/menu components
menuContainer.addEventListener('keydown', (e) => {
const items = [...menuContainer.querySelectorAll('[role="menuitem"]')];
const current = items.indexOf(document.activeElement);
if (e.key === 'ArrowDown') items[(current + 1) % items.length].focus();
if (e.key === 'ArrowUp') items[(current - 1 + items.length) % items.length].focus();
if (e.key === 'Home') items[0].focus();
if (e.key === 'End') items[items.length - 1].focus();
});Skip Links & Focus Management
<!-- Skip link — allows keyboard users to skip repeated navigation -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav>...</nav>
<main id="main-content" tabindex="-1">
<!-- tabindex="-1" allows programmatic focus without adding to tab order -->
Content...
</main>
<style>
.skip-link {
position: absolute;
top: -100%;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
z-index: 9999;
}
.skip-link:focus {
top: 0; /* visible only when focused */
}
</style>Color Contrast & Visual
WCAG AA: 4.5:1 contrast ratio for normal text (< 18px), 3:1 for large text (≥ 18px or bold ≥ 14px)
WCAG AAA: 7:1 for normal text, 4.5:1 for large text
Check with: WebAIM Contrast Checker, Chrome DevTools → CSS Overview → Colors
Don't rely solely on color to convey meaning (e.g., error state) — add icon or text
Focus indicators must be visible — never remove :focus outline without providing custom alternative
Minimum tap target size: 44×44px (iOS HIG), 48×48dp (Material Design)
Keep your own version of these notes — editable, searchable, and organised by your stack.
Start free