Accessibility

Building Accessible Web Applications

Create inclusive web experiences that work for everyone, regardless of abilities.

Plake
Mar 25, 20268 min read1,600 words
Building Accessible Web Applications

Accessibility is not a checklist. It is the practice of building products that work for the widest possible range of people — including those who use screen readers, keyboard navigation, voice control, high-contrast displays, or who have cognitive or motor differences. Beyond the ethical case, accessible sites rank better in SEO, perform better on mobile, and are often required by law (ADA, EN 301 549, EAA). This guide gives you the practical foundation.

WCAG and POUR Principles

The Web Content Accessibility Guidelines (WCAG) organise accessibility into four principles, commonly called POUR:

Principle Meaning
Perceivable Information can be seen or heard — not just one sense
Operable Interface can be used without a mouse
Understandable Content is readable and behaviour is predictable
Robust Works with current and future assistive technologies

WCAG has three conformance levels: A (minimum), AA (standard target for most sites), and AAA (enhanced). Aim for AA as your baseline. Many accessibility laws reference WCAG 2.1 AA specifically.

Semantic HTML: The Foundation

Semantic HTML is the highest-leverage accessibility improvement you can make. Browser accessibility APIs are built on top of it — screen readers, voice control, and keyboard navigation all work correctly with semantic markup for free.

<!-- Bad — div soup tells assistive tech nothing -->
<div class="header">
  <div class="nav">
    <div class="nav-item" onclick="navigate('/blog')">Blog</div>
  </div>
</div>
<div class="main-content">
  <div class="heading">Article Title</div>
  <div class="body-text">Content…</div>
</div>

<!-- Good — structure is meaningful -->
<header>
  <nav aria-label="Main navigation">
    <ul>
      <li><a href="/blog">Blog</a></li>
    </ul>
  </nav>
</header>
<main>
  <article>
    <h1>Article Title</h1>
    <p>Content…</p>
  </article>
</main>

Use elements for their purpose:

  • <button> for interactive controls (not <div onclick>).
  • <a href> for navigation, <button> for actions.
  • <nav>, <main>, <header>, <footer>, <aside> as landmark regions.
  • <h1>–<h6> for a logical heading outline — one <h1> per page, headings that don’t skip levels.
  • <label> paired with every form input.

Keyboard Navigation

Every interactive element must be reachable and operable by keyboard. Tab moves focus forward; Shift+Tab moves it backward; Enter/Space activates buttons and links.

// Bad — div with click handler is not keyboard accessible
<div onClick={handleDelete} className="delete-btn">
  Delete
</div>

// Good — button is focusable and activates on Enter/Space automatically
<button onClick={handleDelete} type="button">
  Delete
</button>

Focus Management

When content changes dynamically — dialogs open, routes change, errors appear — move focus to the relevant element:

// ErrorMessage.jsx — an interactive island
import { useEffect, useRef } from "react";

function ErrorMessage({ message }: { message: string }) {
  const ref = useRef<HTMLParagraphElement>(null);

  useEffect(() => {
    ref.current?.focus();
  }, [message]);

  return (
    <p
      ref={ref}
      role="alert"
      tabIndex={-1} // focusable programmatically but not in tab order
      className="text-red-600"
    >
      {message}
    </p>
  );
}

Custom Focus Styles

Never remove the focus outline with outline: none unless you replace it with something equally visible:

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

/* Good — custom, visible focus ring */
:focus-visible {
  outline: 2px solid #6366f1;
  outline-offset: 3px;
  border-radius: 2px;
}

Use :focus-visible instead of :focus — it shows only when keyboard-navigating, not on mouse click.

ARIA Attributes

ARIA (Accessible Rich Internet Applications) supplements HTML semantics for dynamic and complex widgets. The first rule of ARIA: don’t use ARIA if a native HTML element works.

Common ARIA Patterns

<!-- Labels for icon-only buttons -->
<button aria-label="Close dialog" type="button">
  <svg aria-hidden="true" focusable="false">…</svg>
</button>

<!-- Expand/collapse pattern -->
<button aria-expanded="false" aria-controls="panel-1" type="button">
  Show details
</button>
<div id="panel-1" hidden>…</div>

<!-- Live regions — announce dynamic changes to screen readers -->
<div role="status" aria-live="polite" aria-atomic="true">{statusMessage}</div>

<!-- Error on a form field -->
<label htmlFor="email">Email</label>
<input
  id="email"
  type="email"
  aria-describedby="email-error"
  aria-invalid="{hasError}"
/>
{hasError && (
<p id="email-error" role="alert">Please enter a valid email address.</p>
)}

Dialogs require focus trapping — focus should cycle within the dialog while it is open:

// Modal.jsx — an interactive island
import { useEffect, useRef } from "react";

function Modal({ isOpen, onClose, title, children }) {
  const dialogRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (isOpen) {
      dialogRef.current?.focus();
    }
  }, [isOpen]);

  useEffect(() => {
    function handleEscape(e: KeyboardEvent) {
      if (e.key === "Escape") onClose();
    }
    if (isOpen) document.addEventListener("keydown", handleEscape);
    return () => document.removeEventListener("keydown", handleEscape);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="dialog-title"
      ref={dialogRef}
      tabIndex={-1}
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
    >
      <div
        className="rounded-lg bg-white p-6 shadow-xl"
        style={{ maxWidth: "480px", width: "100%" }}
      >
        <h2 id="dialog-title" className="text-lg font-semibold">
          {title}
        </h2>
        {children}
        <button onClick={onClose} type="button">
          Close
        </button>
      </div>
    </div>
  );
}

For production use, consider Radix UI or Headless UI — they implement these patterns correctly and are well-tested by the community.

Colour and Contrast

WCAG AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (18pt+) and UI components like buttons and inputs.

Use the WebAIM Contrast Checker or the browser’s DevTools accessibility panel to verify.

/* Common failures */
color: #999; on white background   /* 2.85:1 — fails */
color: #767676; on white           /* 4.54:1 — barely passes AA */
color: #595959; on white           /* 7.0:1 — passes AAA */

Don’t convey information by colour alone. Use icons, patterns, or text alongside colour:

// Bad — only colour distinguishes error from success
<span className="text-red-500">{message}</span>

// Good — icon + colour + text
<span className="flex items-center gap-1 text-red-600">
  <AlertCircleIcon aria-hidden="true" />
  <span role="alert">{message}</span>
</span>

Images and Media

Every meaningful image needs descriptive alt text. Decorative images get an empty alt so screen readers skip them:

<!-- Meaningful image -->
<img
  src="/images/deployment-diagram.png"
  alt="Diagram showing requests flowing from browser through CDN to origin server"
/>

<!-- Decorative — empty alt, screen reader skips it -->
<img src="/images/decorative-squiggle.svg" alt="" aria-hidden="true" />

<!-- Icon with adjacent text — hide from screen readers -->
<button>
  <svg aria-hidden="true" focusable="false">…</svg>
  Download PDF
</button>

For videos, provide captions (not auto-generated) and a text transcript. Audio descriptions for content that is only conveyed visually.

Forms

Forms are where accessibility failure hurts most — errors that can’t be understood, fields that aren’t labelled, and focus management that abandons the user.

function ContactForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const firstErrorRef = useRef<HTMLElement>(null);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const newErrors = validate(e.currentTarget as HTMLFormElement);
    setErrors(newErrors);
    if (Object.keys(newErrors).length > 0) {
      // Move focus to the first error so screen readers announce it
      firstErrorRef.current?.focus();
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="name">
          Full name
          <span aria-hidden="true"> *</span>
          <span className="sr-only"> (required)</span>
        </label>
        <input
          id="name"
          type="text"
          required
          autoComplete="name"
          aria-describedby={errors.name ? "name-error" : undefined}
          aria-invalid={!!errors.name}
        />
        {errors.name && (
          <p
            id="name-error"
            role="alert"
            ref={firstErrorRef as any}
            tabIndex={-1}
          >
            {errors.name}
          </p>
        )}
      </div>
      <button type="submit">Send message</button>
    </form>
  );
}

Allow keyboard users to jump past repeated navigation:

<!-- First element in <body> -->
<a
  href="#main-content"
  class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:rounded focus:bg-white focus:px-4 focus:py-2"
>
  Skip to main content
</a>

<!-- … navigation … -->

<main id="main-content" tabindex="-1">
  <!-- page content -->
</main>

Invisible by default, visible when focused by a keyboard user.

Testing for Accessibility

Automated tools

npm install -D jest-axe @axe-core/react
// In tests (catches ~30% of WCAG issues)
import { axe } from "jest-axe";

test("ContactForm has no axe violations", async () => {
  const { container } = render(<ContactForm />);
  expect(await axe(container)).toHaveNoViolations();
});

Browser extensions for manual audit: axe DevTools, WAVE, Accessibility Insights. Run them on every page before you ship.

Screen Reader Testing

OS Screen Reader Browser
macOS VoiceOver (Cmd+F5) Safari
Windows NVDA (free) Chrome / Firefox
Windows JAWS Chrome / Firefox
iOS VoiceOver Safari
Android TalkBack Chrome

Manual testing checklist:

  • Navigate the page using only Tab and arrow keys.
  • All interactive elements reachable and usable?
  • Dialogs trap focus and close on Escape?
  • Dynamic content changes (loading, errors) announced?
  • Images have meaningful alt text?

Keyboard Navigation Checklist

✓ Tab reaches all interactive elements
✓ Focus order is logical (matches visual order)
✓ Focus indicator is visible at all times
✓ Dialogs trap focus
✓ Escape closes dialogs and menus
✓ Dropdowns navigable with arrow keys
✓ Skip navigation link present

Web accessibility illustration

Accessible Component Libraries

Building accessible custom components correctly (dialogs, comboboxes, date pickers) is genuinely hard. Use tested primitives:

  • Radix UI — unstyled, fully accessible primitives.
  • Headless UI — Tailwind’s accessible component collection.
  • React Aria — Adobe’s accessible hook library.
  • shadcn/ui — styled Radix UI components, copy-paste into your project.

Many jurisdictions require accessible websites:

  • USA — ADA (Americans with Disabilities Act), Section 508 for federal sites.
  • EU — EN 301 549, EU Accessibility Act (EAA) effective 2025.
  • UK — Equality Act 2010, Public Sector Bodies Accessibility Regulations.

WCAG 2.1 AA is the referenced standard in most regulations. Failing to meet it has resulted in costly litigation — Target, Domino’s, and many others have faced judgments.

Closing Thoughts

Start with semantic HTML — it gives you keyboard support, screen reader announcements, and logical structure almost for free. Add ARIA only where HTML semantics fall short. Test with an actual keyboard and at least one screen reader before you consider a feature done. Accessibility is not a phase of the project; it is a habit that becomes invisible once you build it in from the start.

Share:

About the author

The Plake Team

Writes long-form essays on craft, software, and the slow web. Curates the Sunday digest read by 88,000+ readers.

Read more

Keep reading

Related Articles

Back to AllBack to All
Mastering Tailwind CSS for Modern Web Design

Feb 20, 2026

6 min

Mastering Tailwind CSS for Modern Web Design