Practical Web Accessibility (a11y) Guide — ARIA, Keyboard, Screen Readers

Why Web Accessibility Matters

Web accessibility (a11y — the 11 letters between “a” and “y” in “accessibility”) is about making the web equally usable for people with disabilities. If a building only has stairs, wheelchair users cannot enter. Accessibility serves as the ramp for the web.

Approximately 15% of the world’s population (1 billion people) has some form of disability. Accessibility is a moral obligation, a legal requirement, and a business opportunity.

The Four WCAG Principles (POUR)

PrincipleMeaningExample
PerceivableAll content must be perceivableProviding alt text for images
OperableAll functionality must be keyboard-accessibleTab navigation, Enter activation
UnderstandableContent and UI must be understandableClear error messages, consistent UI
RobustAccessible via diverse technologiesScreen readers, voice recognition, etc.

Semantic HTML: The Foundation of Accessibility

Semantic HTML accounts for 80% of accessibility. Use the correct HTML elements before resorting to ARIA.

<!-- Wrong: building everything with divs (screen readers cannot determine structure) -->
<div class="header">
  <div class="nav">
    <div class="link" onclick="navigate('/')">Home</div>
    <div class="link" onclick="navigate('/about')">About</div>
  </div>
</div>
<div class="main">
  <div class="article">
    <div class="title">Web Accessibility Guide</div>
    <div class="content">...</div>
  </div>
</div>

<!-- Correct: using semantic elements (automatically conveys roles and structure) -->
<header>
  <nav aria-label="Main navigation">
    <a href="/">Home</a>
    <a href="/about">About</a>
  </nav>
</header>
<main>
  <article>
    <h1>Web Accessibility Guide</h1>
    <p>...</p>
  </article>
</main>
<footer>
  <p>Copyright 2026</p>
</footer>

Semantic Elements to ARIA Role Mapping

HTML ElementImplicit ARIA RoleScreen Reader Reads
navnavigation”navigation”
mainmain”main content”
headerbanner”banner”
footercontentinfo”content info”
buttonbutton”button”
a[href]link”link”
h1~h6heading”heading level N”
ul/ollist”list, N items”

ARIA Attributes in Practice

ARIA (Accessible Rich Internet Applications) adds accessibility information to complex UIs that cannot be expressed with semantic HTML alone.

Core Rule: Prefer Semantic HTML Over ARIA

<!-- Wrong: adding ARIA role to a div -->
<div role="button" tabindex="0" onclick="submit()">Submit</div>

<!-- Correct: using a native button -->
<button onclick="submit()">Submit</button>
<!-- button automatically provides role="button", keyboard support, and focus management -->

Common ARIA Patterns

<!-- 1. Accordion -->
<button
  aria-expanded="false"
  aria-controls="panel-1"
  id="accordion-1">
  Frequently Asked Questions
</button>
<div
  id="panel-1"
  role="region"
  aria-labelledby="accordion-1"
  hidden>
  <p>Accessibility is for all users.</p>
</div>

<!-- 2. Tab Panel -->
<div role="tablist" aria-label="Content tabs">
  <button role="tab" aria-selected="true"
          aria-controls="tab-panel-1" id="tab-1">
    Overview
  </button>
  <button role="tab" aria-selected="false"
          aria-controls="tab-panel-2" id="tab-2"
          tabindex="-1">
    Details
  </button>
</div>
<div role="tabpanel" id="tab-panel-1"
     aria-labelledby="tab-1">
  <p>Overview content goes here.</p>
</div>
<div role="tabpanel" id="tab-panel-2"
     aria-labelledby="tab-2" hidden>
  <p>Detailed content goes here.</p>
</div>

<!-- 3. Live Region (screen reader announces immediately) -->
<div aria-live="polite" aria-atomic="true" id="status">
  <!-- Dynamic messages inserted via JavaScript -->
</div>

<!-- 4. Modal Dialog -->
<dialog aria-labelledby="modal-title" aria-modal="true">
  <h2 id="modal-title">Confirm Deletion</h2>
  <p>Are you sure you want to delete this?</p>
  <button>Confirm</button>
  <button>Cancel</button>
</dialog>

Keyboard Navigation

All interactive elements must be keyboard-accessible.

// Tab panel keyboard navigation implementation
document.querySelector("[role='tablist']").addEventListener("keydown", (e) => {
  const tabs = [...e.currentTarget.querySelectorAll("[role='tab']")];
  const currentIndex = tabs.indexOf(e.target);

  let newIndex;

  switch (e.key) {
    case "ArrowRight": // Right arrow → next tab
      newIndex = (currentIndex + 1) % tabs.length;
      break;
    case "ArrowLeft":  // Left arrow → previous tab
      newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
      break;
    case "Home":       // Home → first tab
      newIndex = 0;
      break;
    case "End":        // End → last tab
      newIndex = tabs.length - 1;
      break;
    default:
      return; // Ignore other keys
  }

  // Move focus and activate tab
  tabs[newIndex].focus();
  tabs[newIndex].click();
  e.preventDefault();
});
/* Focus styles — visual indicator for keyboard users */

/* Wrong: removing focus styles */
*:focus { outline: none; } /* Never do this! */

/* Correct: clear focus indicator */
:focus-visible {
  outline: 3px solid #4f46e5;     /* Indigo outline */
  outline-offset: 2px;            /* 2px offset from element */
  border-radius: 4px;
}

/* Hide outline on mouse click, show only during keyboard navigation */
:focus:not(:focus-visible) {
  outline: none;
}

/* Skip link — lets keyboard users skip repetitive navigation */
.skip-link {
  position: absolute;
  top: -100%;
  left: 0;
  z-index: 9999;
  padding: 1rem;
  background: #1e1b4b;
  color: white;
}

.skip-link:focus {
  top: 0;                         /* Shown on screen when focused */
}
<!-- Skip link usage -->
<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>
  <header>...</header>
  <nav>...</nav>
  <main id="main-content">
    <!-- Main content -->
  </main>
</body>

Images and Alternative Text

<!-- Informational image — provide a specific description -->
<img src="chart.png" alt="Quarterly revenue for 2025 — Q1 $10M, Q2 $15M, Q3 $20M" />

<!-- Decorative image — empty alt (screen readers skip it) -->
<img src="decoration.svg" alt="" />

<!-- Image inside a link — describe the link's purpose -->
<a href="/profile">
  <img src="avatar.jpg" alt="Go to my profile" />
</a>

<!-- Complex image — link to a detailed description -->
<figure>
  <img src="architecture.png"
       alt="Microservices architecture diagram"
       aria-describedby="arch-desc" />
  <figcaption id="arch-desc">
    The API Gateway routes requests to three services (Auth, Product, Order),
    each using an independent database.
  </figcaption>
</figure>

Form Accessibility

<!-- Properly structured form -->
<form aria-labelledby="form-title">
  <h2 id="form-title">Sign Up</h2>

  <!-- Label and input association is required -->
  <div>
    <label for="user-email">Email (required)</label>
    <input
      type="email"
      id="user-email"
      name="email"
      required
      aria-required="true"
      aria-describedby="email-hint email-error"
      autocomplete="email" />
    <p id="email-hint">e.g., user@example.com</p>
    <p id="email-error" role="alert" hidden>
      Please enter a valid email address.
    </p>
  </div>

  <!-- Password — requirement instructions -->
  <div>
    <label for="user-pw">Password (required)</label>
    <input
      type="password"
      id="user-pw"
      name="password"
      required
      aria-required="true"
      aria-describedby="pw-requirements"
      minlength="8"
      autocomplete="new-password" />
    <ul id="pw-requirements">
      <li>At least 8 characters</li>
      <li>Must include letters, numbers, and special characters</li>
    </ul>
  </div>

  <button type="submit">Sign Up</button>
</form>

Accessibility Testing Tools

ToolTypePurpose
axe DevToolsBrowser extensionAutomated accessibility testing
LighthouseBuilt into ChromeAccessibility score measurement
NVDAScreen reader (Windows)Real screen reader testing
VoiceOverScreen reader (macOS)Real screen reader testing
Colour Contrast AnalyserDesktop appColor contrast verification
# Automated testing with axe-core CLI
npx @axe-core/cli https://example.com

# Integrate accessibility testing into component tests with jest-axe
npm install --save-dev jest-axe

Practical Tips

  • Use semantic HTML first: Most accessibility issues can be resolved simply by using the correct HTML elements. Avoid overusing div and span.
  • Navigate your site using only the keyboard: Test all functionality with Tab, Shift+Tab, Enter, Space, Escape, and arrow keys.
  • Never remove :focus-visible styles: Removing focus indicators leaves keyboard users unable to see where they are on the page.
  • Do not rely on color alone: If errors are indicated only by red color, users with color vision deficiencies cannot perceive them. Always provide icons or text alongside color.
  • ARIA is a last resort: Incorrect ARIA usage actually worsens accessibility. Remember the rule: “No ARIA is better than bad ARIA.”
  • Test with real screen readers: NVDA (free) on Windows and VoiceOver (built-in) on macOS provide the most reliable way to verify accessibility by listening directly.

Was this article helpful?