Skip to content
Codeloom
Web

Web Accessibility Checklist: WCAG 2.1 in Practice

A developer's checklist for WCAG 2.1 compliance: semantic HTML, ARIA patterns, keyboard navigation, focus management, screen reader testing, and automated auditing.

·8 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • A practical checklist of WCAG 2.1 Level AA requirements
  • How to implement ARIA patterns for common widgets
  • How to build keyboard-navigable components from scratch
  • How to manage focus for modals, drawers, and dynamic content
  • How to test with screen readers and automated tools

Prerequisites

  • HTML and CSS fundamentals
  • Basic JavaScript

The Checklist Approach

WCAG has 78 success criteria across three levels (A, AA, AAA). Most legal requirements and business targets aim for Level AA. This article organizes the most impactful criteria into a checklist you can work through on any project.

Semantic HTML

Most accessibility problems are solved by using the right HTML element instead of a generic <div>.

The Essentials

<!-- BAD: div soup -->
<div class="nav">
  <div class="nav-item" onclick="navigate('/home')">Home</div>
  <div class="nav-item" onclick="navigate('/about')">About</div>
</div>

<!-- GOOD: semantic elements -->
<nav aria-label="Main navigation">
  <ul>
    <li><a href="/home">Home</a></li>
    <li><a href="/about">About</a></li>
  </ul>
</nav>

Landmark Regions

<body>
  <header><!-- Site header, logo, nav --></header>
  <nav aria-label="Main"><!-- Primary navigation --></nav>
  <main>
    <article>
      <h1>Page Title</h1>
      <!-- Primary content -->
    </article>
    <aside aria-label="Related articles">
      <!-- Sidebar content -->
    </aside>
  </main>
  <footer><!-- Copyright, links --></footer>
</body>

Screen readers let users jump between landmarks. If you wrap everything in <div>s, this navigation is impossible.

Heading Hierarchy

<!-- BAD: skipping heading levels -->
<h1>Site Name</h1>
<h4>Section Title</h4>  <!-- Skipped h2 and h3 -->

<!-- GOOD: sequential heading levels -->
<h1>Page Title</h1>
<h2>Section One</h2>
<h3>Subsection</h3>
<h2>Section Two</h2>

ARIA Patterns

ARIA should be used only when HTML semantics are insufficient. The first rule of ARIA: do not use ARIA if a native HTML element does the job.

Tabs

<div role="tablist" aria-label="Settings">
  <button role="tab" id="tab-general" aria-selected="true"
          aria-controls="panel-general" tabindex="0">
    General
  </button>
  <button role="tab" id="tab-security" aria-selected="false"
          aria-controls="panel-security" tabindex="-1">
    Security
  </button>
  <button role="tab" id="tab-notifications" aria-selected="false"
          aria-controls="panel-notifications" tabindex="-1">
    Notifications
  </button>
</div>

<div role="tabpanel" id="panel-general" aria-labelledby="tab-general">
  <!-- General settings content -->
</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>
  <!-- Security settings content -->
</div>
<div role="tabpanel" id="panel-notifications" aria-labelledby="tab-notifications" hidden>
  <!-- Notifications settings content -->
</div>

Keyboard behavior for tabs:

  • Arrow Left/Right moves between tabs
  • Home/End jumps to first/last tab
  • Tab key moves focus into the panel content
const tablist = document.querySelector('[role="tablist"]');
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));

tablist.addEventListener('keydown', (event) => {
  const currentIndex = tabs.indexOf(event.target as HTMLElement);
  let newIndex = currentIndex;

  switch (event.key) {
    case 'ArrowRight':
      newIndex = (currentIndex + 1) % tabs.length;
      break;
    case 'ArrowLeft':
      newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
      break;
    case 'Home':
      newIndex = 0;
      break;
    case 'End':
      newIndex = tabs.length - 1;
      break;
    default:
      return;
  }

  event.preventDefault();
  activateTab(tabs[newIndex]);
});

function activateTab(tab: HTMLElement) {
  // Deactivate all tabs
  tabs.forEach((t) => {
    t.setAttribute('aria-selected', 'false');
    t.setAttribute('tabindex', '-1');
    const panel = document.getElementById(t.getAttribute('aria-controls')!);
    panel?.setAttribute('hidden', '');
  });

  // Activate selected tab
  tab.setAttribute('aria-selected', 'true');
  tab.setAttribute('tabindex', '0');
  tab.focus();

  const panel = document.getElementById(tab.getAttribute('aria-controls')!);
  panel?.removeAttribute('hidden');
}
<dialog id="confirm-dialog" aria-labelledby="dialog-title" aria-describedby="dialog-desc">
  <h2 id="dialog-title">Delete Item?</h2>
  <p id="dialog-desc">This action cannot be undone.</p>
  <div class="dialog-actions">
    <button id="dialog-cancel">Cancel</button>
    <button id="dialog-confirm" class="danger">Delete</button>
  </div>
</dialog>
const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;

function openDialog() {
  dialog.showModal(); // Native <dialog> handles focus trapping
}

// Close on Escape is handled automatically by <dialog>
dialog.addEventListener('close', () => {
  // Return focus to the trigger element
  triggerButton.focus();
});

// Close on backdrop click
dialog.addEventListener('click', (event) => {
  if (event.target === dialog) {
    dialog.close();
  }
});

The native <dialog> element with showModal() handles focus trapping automatically. You do not need to implement it yourself.

Live Regions

For dynamic content updates that screen readers should announce:

<!-- Polite: announced after current speech finishes -->
<div aria-live="polite" aria-atomic="true" id="status-message"></div>

<!-- Assertive: interrupts current speech (use sparingly) -->
<div aria-live="assertive" id="error-message"></div>
function showStatus(message: string) {
  const el = document.getElementById('status-message');
  if (el) el.textContent = message;
}

// After a form submission:
showStatus('Profile saved successfully.');

Keyboard Navigation

Focus Indicators

Never remove focus outlines without providing an alternative:

/* BAD */
*:focus { outline: none; }

/* GOOD: custom focus indicator */
:focus-visible {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

/* Remove outline for mouse clicks, keep for keyboard */
:focus:not(:focus-visible) {
  outline: none;
}
<body>
  <a href="#main-content" class="skip-link">Skip to content</a>
  <header><!-- navigation --></header>
  <main id="main-content" tabindex="-1">
    <!-- page content -->
  </main>
</body>
.skip-link {
  position: absolute;
  top: -100%;
  left: 1rem;
  padding: 0.5rem 1rem;
  background: #0f172a;
  color: white;
  z-index: 10000;
  border-radius: 0 0 0.5rem 0.5rem;
}

.skip-link:focus {
  top: 0;
}

Custom Interactive Elements

If you must build a custom button or control:

<!-- Custom toggle switch -->
<div role="switch" aria-checked="false" aria-label="Dark mode" tabindex="0"
     class="toggle-switch" id="dark-mode-toggle">
  <span class="toggle-track">
    <span class="toggle-thumb"></span>
  </span>
</div>
const toggle = document.getElementById('dark-mode-toggle')!;

toggle.addEventListener('click', () => {
  const isChecked = toggle.getAttribute('aria-checked') === 'true';
  toggle.setAttribute('aria-checked', String(!isChecked));
});

toggle.addEventListener('keydown', (event) => {
  if (event.key === ' ' || event.key === 'Enter') {
    event.preventDefault();
    toggle.click();
  }
});

Color and Contrast

Minimum Contrast Ratios

  • Normal text (under 18px): 4.5:1 contrast ratio (Level AA)
  • Large text (18px+ bold or 24px+): 3:1 contrast ratio
  • UI components and icons: 3:1 contrast ratio
/* Check these values against a contrast checker */
:root {
  --text-primary: #e2e8f0;     /* On #0f172a: 12.6:1 - passes */
  --text-secondary: #94a3b8;   /* On #0f172a: 5.6:1 - passes */
  --text-muted: #475569;       /* On #0f172a: 2.6:1 - FAILS for small text */
}

Do Not Rely on Color Alone

<!-- BAD: status indicated only by color -->
<span style="color: red;">Error</span>
<span style="color: green;">Success</span>

<!-- GOOD: color + text/icon -->
<span class="error">Error: Invalid email address</span>
<span class="success">Success: Profile saved</span>

Forms

Labels and Descriptions

<form>
  <div class="field">
    <label for="email">Email address</label>
    <input type="email" id="email" name="email"
           aria-describedby="email-hint" required />
    <p id="email-hint" class="hint">We will never share your email.</p>
  </div>

  <div class="field">
    <label for="password">Password</label>
    <input type="password" id="password" name="password"
           aria-describedby="password-requirements" required
           minlength="8" />
    <p id="password-requirements" class="hint">
      At least 8 characters with one number and one symbol.
    </p>
  </div>

  <!-- Error messages linked to inputs -->
  <div class="field" aria-invalid="true">
    <label for="username">Username</label>
    <input type="text" id="username" name="username"
           aria-describedby="username-error" aria-invalid="true" />
    <p id="username-error" class="error" role="alert">
      Username is already taken.
    </p>
  </div>
</form>

Form Validation

const form = document.querySelector('form')!;

form.addEventListener('submit', (event) => {
  const errors: string[] = [];

  // Validate each field
  const email = form.querySelector('#email') as HTMLInputElement;
  if (!email.validity.valid) {
    errors.push('Please enter a valid email address.');
    email.setAttribute('aria-invalid', 'true');
  }

  if (errors.length > 0) {
    event.preventDefault();

    // Announce errors to screen readers
    const errorSummary = document.getElementById('error-summary')!;
    errorSummary.innerHTML = `
      <h2>Please fix the following errors:</h2>
      <ul>${errors.map((e) => `<li>${e}</li>`).join('')}</ul>
    `;
    errorSummary.focus();
  }
});

Images and Media

<!-- Informative image: describe what it shows -->
<img src="/chart.png" alt="Bar chart showing 40% increase in Q3 revenue" />

<!-- Decorative image: empty alt -->
<img src="/divider.svg" alt="" role="presentation" />

<!-- Complex image: use a longer description -->
<figure>
  <img src="/architecture.png" alt="System architecture diagram"
       aria-describedby="arch-desc" />
  <figcaption id="arch-desc">
    The system has three layers: a React frontend communicating via REST
    with a Node.js API, which connects to a PostgreSQL database.
  </figcaption>
</figure>

<!-- Video with captions -->
<video controls>
  <source src="/demo.mp4" type="video/mp4" />
  <track kind="captions" src="/demo-captions.vtt" srclang="en" label="English" default />
</video>

Testing Checklist

Automated Tools

# Install axe-core for automated testing
npm install -D @axe-core/cli

# Run against a URL
npx axe http://localhost:4321

# Or use in tests
npm install -D @axe-core/playwright
// In a Playwright test
import AxeBuilder from '@axe-core/playwright';

test('homepage has no accessibility violations', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toEqual([]);
});

Manual Testing Checklist

  1. Keyboard: Tab through the entire page. Can you reach every interactive element? Can you operate every control? Can you see where focus is?
  2. Screen reader: Test with VoiceOver (Mac), NVDA (Windows), or JAWS. Navigate by headings, landmarks, and links.
  3. Zoom: Zoom to 200%. Does content reflow? Is anything cut off or overlapping?
  4. Reduced motion: Enable “Reduce Motion” in OS settings. Do animations respect prefers-reduced-motion?
  5. Color contrast: Use a contrast checker on every text/background combination.
  6. No color alone: Turn on grayscale mode. Can you still understand the interface?
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Summary

Accessibility is not a separate project. It is a set of habits: use semantic HTML, add ARIA only when needed, make everything keyboard-operable, maintain color contrast, and test with real assistive technology. The native <dialog> element handles focus trapping. Live regions announce dynamic changes. Skip links help keyboard users bypass navigation. And automated tools like axe-core catch the mechanical issues so you can focus on the ones that require human judgment.