HTML
11 / 12

Accessibility (ARIA & a11y)

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