←Back to Blog
β€’8 min readβ€’Frontend

Web Accessibility in 2025: Building Inclusive Digital Experiences

Master modern web accessibility with WCAG guidelines, ARIA patterns, and practical techniques for building websites that work for everyone.

Web Accessibility in 2025: Building Inclusive Digital Experiences

Web accessibility (a11y) isn't optionalβ€”it's essential. With 1 in 4 people experiencing some form of disability, accessible design benefits everyone and is increasingly required by law.

Why Accessibility Matters

Legal Requirements

  • ADA (Americans with Disabilities Act)
  • Section 508 (US Federal websites)
  • EAA (European Accessibility Act)
  • AODA (Canada)

Lawsuits against inaccessible websites are increasing.

Business Benefits

  • Larger audience: 1+ billion people with disabilities
  • Better SEO: Accessible HTML helps search engines
  • Improved UX: Benefits everyone (mobile users, elderly, etc.)
  • Brand reputation: Shows you care about all users

Moral Imperative

Everyone deserves equal access to information and services.

WCAG Principles

Web Content Accessibility Guidelines (WCAG) 2.1 has four principles:

1. Perceivable

Users must be able to perceive information.

Example violations:

<!-- ❌ Missing alt text -->
<img src="product.jpg">

<!-- βœ… Descriptive alt text -->
<img src="product.jpg" alt="Blue wireless headphones with case">

<!-- ❌ Low contrast -->
<p style="color: #999; background: #fff;">Text</p>
<!-- Contrast ratio: 2.85:1 (fails WCAG AA)

<!-- βœ… Sufficient contrast -->
<p style="color: #595959; background: #fff;">Text</p>
<!-- Contrast ratio: 4.54:1 (passes WCAG AA) -->

2. Operable

Users must be able to operate the interface.

Example violations:

<!-- ❌ No keyboard access -->
<div onclick="handleClick()">Click me</div>

<!-- βœ… Keyboard accessible -->
<button onclick="handleClick()">Click me</button>

<!-- ❌ Insufficient click target size -->
<a href="#" style="font-size: 10px; padding: 2px;">Link</a>

<!-- βœ… Adequate click target (44x44px minimum) -->
<a href="#" style="display: inline-block; padding: 12px;">Link</a>

3. Understandable

Users must be able to understand information and interface.

Example violations:

<!-- ❌ No label -->
<input type="email">

<!-- βœ… Clear label -->
<label for="email">Email address</label>
<input type="email" id="email" name="email">

<!-- ❌ Unclear error -->
<span>Invalid</span>

<!-- βœ… Clear error message -->
<span role="alert">Email address must be in format: user@example.com</span>

4. Robust

Content must work with current and future tools.

Example violations:

<!-- ❌ Invalid HTML -->
<div role="button" tabindex="0" onclick="submit()">

<!-- βœ… Semantic HTML -->
<button type="submit">Submit</button>

Semantic HTML

Use the right element for the job:

Navigation

<!-- ❌ Divs everywhere -->
<div class="header">
  <div class="nav">
    <div class="nav-item"><a href="/">Home</a></div>
  </div>
</div>

<!-- βœ… Semantic HTML -->
<header>
  <nav aria-label="Main navigation">
    <ul>
      <li><a href="/">Home</a></li>
      <li><a href="/about">About</a></li>
    </ul>
  </nav>
</header>

Headings

<!-- ❌ Skipping heading levels -->
<h1>Page Title</h1>
<h3>Section</h3>

<!-- βœ… Logical heading hierarchy -->
<h1>Page Title</h1>
<h2>Main Section</h2>
<h3>Subsection</h3>

Buttons vs Links

<!-- ❌ Link that acts like button -->
<a href="#" onclick="openModal()">Open</a>

<!-- βœ… Use button for actions -->
<button type="button" onclick="openModal()">Open</button>

<!-- βœ… Use link for navigation -->
<a href="/products">View Products</a>

ARIA (Accessible Rich Internet Applications)

ARIA adds semantics when HTML isn't enough.

ARIA Roles

<!-- Modal dialog -->
<div role="dialog" aria-labelledby="dialog-title" aria-modal="true">
  <h2 id="dialog-title">Confirm Action</h2>
  <p>Are you sure?</p>
  <button>Confirm</button>
  <button>Cancel</button>
</div>

<!-- Search region -->
<div role="search">
  <label for="search-input">Search</label>
  <input type="search" id="search-input">
  <button>Search</button>
</div>

<!-- Alert -->
<div role="alert">
  Your session will expire in 5 minutes
</div>

ARIA States and Properties

<!-- Expandable section -->
<button
  aria-expanded="false"
  aria-controls="section-content"
  onclick="toggle()"
>
  Toggle Section
</button>
<div id="section-content" hidden>
  Content here
</div>

<!-- Loading state -->
<button aria-busy="true" disabled>
  <span aria-live="polite">Loading...</span>
</button>

<!-- Required field -->
<label for="email">
  Email
  <span aria-label="required">*</span>
</label>
<input
  type="email"
  id="email"
  aria-required="true"
  aria-invalid="false"
>

<!-- Form errors -->
<input
  type="email"
  id="email"
  aria-invalid="true"
  aria-describedby="email-error"
>
<span id="email-error" role="alert">
  Please enter a valid email address
</span>

ARIA Live Regions

<!-- Announce changes -->
<div aria-live="polite" aria-atomic="true">
  <span>3 items in cart</span>
</div>

<!-- Urgent announcements -->
<div aria-live="assertive" role="alert">
  Error: Payment failed
</div>

<!-- Status updates -->
<div role="status" aria-live="polite">
  Saving...
</div>

Keyboard Navigation

Ensure everything works with keyboard:

Tab Order

<!-- Natural tab order -->
<input type="text" tabindex="0">
<button tabindex="0">Submit</button>

<!-- Explicitly set tab order (avoid if possible) -->
<input type="text" tabindex="1">
<button tabindex="2">Submit</button>

<!-- Remove from tab order -->
<div tabindex="-1">Not keyboard focusable</div>

Focus Management

// Modal: trap focus inside
function trapFocus(element) {
  const focusable = element.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );

  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  element.addEventListener('keydown', (e) => {
    if (e.key === 'Tab') {
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  });

  first.focus();
}

// Close modal on Escape
dialog.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    closeDialog();
  }
});

Skip Links

<a href="#main-content" class="skip-link">
  Skip to main content
</a>

<header>...</header>

<main id="main-content">
  <!-- Main content -->
</main>
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  padding: 8px;
  background: #000;
  color: #fff;
}

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

Forms

Make forms accessible:

Labels

<!-- βœ… Explicit label -->
<label for="username">Username</label>
<input type="text" id="username" name="username">

<!-- βœ… Implicit label -->
<label>
  Username
  <input type="text" name="username">
</label>

<!-- βœ… aria-label for icon buttons -->
<button aria-label="Search">
  <svg>...</svg>
</button>

Field Groups

<fieldset>
  <legend>Contact Method</legend>

  <label>
    <input type="radio" name="contact" value="email">
    Email
  </label>

  <label>
    <input type="radio" name="contact" value="phone">
    Phone
  </label>
</fieldset>

Error Handling

<form>
  <div>
    <label for="email">Email</label>
    <input
      type="email"
      id="email"
      aria-required="true"
      aria-invalid="true"
      aria-describedby="email-error"
    >
    <span id="email-error" role="alert">
      Email must be in format: user@example.com
    </span>
  </div>

  <button type="submit">Submit</button>
</form>

Autocomplete

<input
  type="text"
  name="name"
  autocomplete="name"
  aria-autocomplete="list"
  aria-controls="suggestions"
>

<ul id="suggestions" role="listbox">
  <li role="option">John Doe</li>
  <li role="option">Jane Smith</li>
</ul>

Images and Media

Alt Text

<!-- βœ… Descriptive alt -->
<img src="chart.png" alt="Bar chart showing 50% increase in sales from 2022 to 2023">

<!-- βœ… Decorative image -->
<img src="decoration.png" alt="" role="presentation">

<!-- βœ… Complex image with long description -->
<img
  src="infographic.png"
  alt="Customer satisfaction survey results"
  aria-describedby="infographic-description"
>
<div id="infographic-description">
  Detailed description of the infographic...
</div>

Video

<video controls>
  <source src="video.mp4" type="video/mp4">

  <!-- Captions -->
  <track kind="captions" src="captions-en.vtt" srclang="en" label="English">

  <!-- Subtitles -->
  <track kind="subtitles" src="subs-es.vtt" srclang="es" label="EspaΓ±ol">

  <!-- Audio description -->
  <track kind="descriptions" src="descriptions.vtt" srclang="en">
</video>

Audio

<audio controls>
  <source src="podcast.mp3" type="audio/mpeg">
</audio>

<!-- Provide transcript -->
<details>
  <summary>Transcript</summary>
  <p>Full transcript of audio content...</p>
</details>

Color and Contrast

Sufficient Contrast

/* ❌ Fails WCAG AA (2.85:1) */
.text {
  color: #999;
  background: #fff;
}

/* βœ… Passes WCAG AA (4.54:1) */
.text {
  color: #595959;
  background: #fff;
}

/* βœ… Passes WCAG AAA (7.00:1) */
.text {
  color: #333;
  background: #fff;
}

Minimum ratios:

  • Normal text: 4.5:1 (AA), 7:1 (AAA)
  • Large text (18pt+): 3:1 (AA), 4.5:1 (AAA)

Don't Rely on Color Alone

<!-- ❌ Color only -->
<span style="color: red;">Error</span>
<span style="color: green;">Success</span>

<!-- βœ… Color + icon/text -->
<span style="color: red;">
  <svg aria-label="Error">...</svg>
  Error: Invalid input
</span>
<span style="color: green;">
  <svg aria-label="Success">...</svg>
  Success: Saved
</span>

Testing

Automated Tools

# axe-core
npm install --save-dev @axe-core/cli
npx axe https://example.com

# Lighthouse
lighthouse https://example.com --only-categories=accessibility

# pa11y
npm install -g pa11y
pa11y https://example.com

React Testing

import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('should have no accessibility violations', async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Manual Testing

Keyboard:

  1. Tab through page
  2. Ensure logical order
  3. Visible focus indicators
  4. No keyboard traps

Screen reader:

  • NVDA (Windows, free)
  • JAWS (Windows, paid)
  • VoiceOver (Mac, built-in)
  • TalkBack (Android)

Browser extensions:

  • axe DevTools
  • WAVE
  • Lighthouse

Common Patterns

Modal Dialog

function Modal({ isOpen, onClose, title, children }) {
  const modalRef = useRef();

  useEffect(() => {
    if (isOpen) {
      // Save last focused element
      const previouslyFocused = document.activeElement;

      // Focus modal
      modalRef.current?.focus();

      // Restore focus on close
      return () => previouslyFocused?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={modalRef}
      tabIndex="-1"
    >
      <h2 id="modal-title">{title}</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}

Dropdown Menu

function Dropdown({ label, items }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button
        aria-expanded={isOpen}
        aria-haspopup="true"
        onClick={() => setIsOpen(!isOpen)}
      >
        {label}
      </button>

      {isOpen && (
        <ul role="menu">
          {items.map((item) => (
            <li role="none" key={item.id}>
              <a
                href={item.href}
                role="menuitem"
                onClick={() => setIsOpen(false)}
              >
                {item.label}
              </a>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Tabs

function Tabs({ tabs }) {
  const [active, setActive] = useState(0);

  return (
    <div>
      <div role="tablist">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            role="tab"
            aria-selected={active === index}
            aria-controls={`panel-${tab.id}`}
            id={`tab-${tab.id}`}
            onClick={() => setActive(index)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, index) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={active !== index}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

Accessibility Checklist

βœ… Semantic HTML: Use correct elements βœ… Keyboard: Everything accessible via keyboard βœ… Focus: Visible focus indicators βœ… Labels: All inputs have labels βœ… Alt text: Images have appropriate alt text βœ… Headings: Logical heading hierarchy βœ… Contrast: Sufficient color contrast βœ… ARIA: Appropriate ARIA when needed βœ… Testing: Automated and manual testing βœ… Screen reader: Test with screen readers

Resources

Conclusion

Accessibility isn't a featureβ€”it's a fundamental requirement. By following WCAG guidelines, using semantic HTML, and testing thoroughly, you can create experiences that work for everyone.

Start today:

  1. Run automated tests
  2. Test with keyboard
  3. Fix critical issues
  4. Learn and improve incrementally

Remember: Accessible design is good design. When you build for everyone, everyone benefits.

Are your websites accessible?

πŸ‘¨β€πŸ’»

Jordan Patel

Web Developer & Technology Enthusiast