Skip links

Building Accessible Web Applications Without Losing Your Mind

Accessibility Isn’t a Feature — It’s a Baseline

Here’s the uncomfortable truth: if your web application can’t be used by someone with a disability, it’s broken. Not “less optimal.” Not “could be improved.” Broken. In the same way that a form that doesn’t submit is broken, or a link that doesn’t navigate is broken.

Article Overview

Building Accessible Web Applications Without Losing Your …

12 sections · Reading flow

01
Accessibility Isn't a Feature — It's a Baseline
02
The Four Principles (POUR) in 60 Seconds
03
Semantic HTML: The 80% Solution
04
Keyboard Navigation: The Non-Negotiable
05
ARIA: The Power Tool You Should Use Sparingly
06
Color and Contrast
07
Forms: Where Accessibility Gets Real
08
Images and Media
09
Testing Your Accessibility
10
Building an Accessible Component Library
11
The Business Case
12
Making It Sustainable

HARBOR SOFTWARE · Engineering Insights

At Harbor Software, we’ve learned this lesson the hard way. We built an AI-powered document analysis tool that worked flawlessly — unless you used a screen reader, at which point the entire analysis results section was invisible. The custom graph components, the status indicators, the action buttons — all inaccessible. Not because we were careless, but because accessibility wasn’t part of our development workflow. It was an afterthought, and afterthoughts get forgotten.

This guide is not a WCAG specification walkthrough. It’s a practical playbook for building accessible applications without slowing down your development velocity. Every pattern here has been tested in production, and every recommendation comes from actual accessibility audits and user feedback.

The Four Principles (POUR) in 60 Seconds

WCAG organizes accessibility around four principles. You don’t need to memorize the specification, but you need to internalize these concepts:

  • Perceivable: Users must be able to perceive the content. This means text alternatives for images, captions for video, sufficient color contrast, and content that doesn’t rely solely on color to convey meaning.
  • Operable: Users must be able to operate the interface. This means keyboard navigation for everything, no keyboard traps, sufficient time to read content, and no content that causes seizures.
  • Understandable: Users must be able to understand the content and interface. This means readable text, predictable navigation, and helpful error messages.
  • Robust: Content must be robust enough to be interpreted by assistive technologies. This means valid HTML, proper ARIA usage, and semantic markup.

Semantic HTML: The 80% Solution

The single most impactful thing you can do for accessibility is use the correct HTML elements. Semantic HTML carries built-in accessibility behavior — keyboard interaction, screen reader announcements, focus management — that you would otherwise have to implement manually with JavaScript and ARIA.

<!-- BAD: div soup (inaccessible by default) -->
<div class="header">
  <div class="nav">
    <div class="nav-item" onclick="navigate('/')">Home</div>
    <div class="nav-item" onclick="navigate('/about')">About</div>
  </div>
</div>
<div class="main">
  <div class="title">Welcome</div>
  <div class="text">Some content here</div>
  <div class="btn" onclick="submit()">Submit</div>
</div>

<!-- GOOD: semantic HTML (accessible by default) -->
<header>
  <nav aria-label="Main navigation">
    <a href="/">Home</a>
    <a href="/about">About</a>
  </nav>
</header>
<main>
  <h1>Welcome</h1>
  <p>Some content here</p>
  <button type="submit">Submit</button>
</main>

The semantic version is keyboard-navigable out of the box. The <a> tags are focusable and activated with Enter. The <button> is focusable and activated with Enter or Space. Screen readers announce the <nav> as a navigation landmark, the <main> as the main content area, and the <h1> as a heading. The div version does none of this. You would need to add tabindex, role, aria-label, keyboard event handlers, and focus styles to every single element.

The rule we follow: if a native HTML element does what you need, use it. Only reach for ARIA when there’s no native equivalent.

Keyboard Navigation: The Non-Negotiable

Every interactive element in your application must be operable with a keyboard. This isn’t just for screen reader users — it’s for power users, users with motor impairments, users with broken trackpads, and users who simply prefer keyboard navigation.

Focus Management in React

When you open a modal, focus should move into the modal. When you close it, focus should return to the trigger element. This is called focus management, and getting it wrong is one of the most common accessibility failures.

import { useRef, useEffect, useCallback } from 'react';

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

  useEffect(() => {
    if (isOpen) {
      // Save the element that triggered the modal
      triggerRef.current = document.activeElement;
      // Move focus into the modal
      modalRef.current?.focus();
    } else if (triggerRef.current) {
      // Return focus to the trigger when modal closes
      triggerRef.current.focus();
    }
  }, [isOpen]);

  // Trap focus inside the modal
  const handleKeyDown = useCallback((e) => {
    if (e.key === 'Escape') {
      onClose();
      return;
    }

    if (e.key !== 'Tab') return;

    const focusableElements = modalRef.current?.querySelectorAll(
      'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
    );

    if (!focusableElements?.length) return;

    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    if (e.shiftKey && document.activeElement === firstElement) {
      e.preventDefault();
      lastElement.focus();
    } else if (!e.shiftKey && document.activeElement === lastElement) {
      e.preventDefault();
      firstElement.focus();
    }
  }, [onClose]);

  if (!isOpen) return null;

  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        onKeyDown={handleKeyDown}
        onClick={(e) => e.stopPropagation()}
        className="bg-white rounded-lg p-6 max-w-lg w-full"
      >
        <h2 id="modal-title">{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

This modal traps focus (Tab doesn’t leave the modal), supports Escape to close, moves focus into the modal on open, and returns focus to the trigger on close. These four behaviors are required by WCAG for any dialog.

Skip Links

Users who navigate by keyboard shouldn’t have to tab through 30 navigation items to reach the main content. A skip link provides a shortcut:

<body>
  <a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:bg-white focus:px-4 focus:py-2 focus:z-50 focus:rounded">
    Skip to main content
  </a>
  <header>{/* navigation */}</header>
  <main id="main-content" tabIndex={-1}>
    {/* page content */}
  </main>
</body>

The sr-only class hides it visually but keeps it in the tab order. When a keyboard user presses Tab on page load, the skip link appears. It’s a 5-minute implementation that saves keyboard users significant time on every page.

ARIA: The Power Tool You Should Use Sparingly

ARIA (Accessible Rich Internet Applications) attributes add semantic information to elements when native HTML doesn’t provide what you need. But the first rule of ARIA is: don’t use ARIA if you can use a native HTML element instead.

Common ARIA patterns we use daily:

<!-- Live regions: announce dynamic content changes -->
<div aria-live="polite" aria-atomic="true">
  {notification && <p>{notification.message}</p>}
</div>

<!-- Describing relationships between elements -->
<label id="email-label">Email</label>
<p id="email-hint">We'll never share your email.</p>
<input
  type="email"
  aria-labelledby="email-label"
  aria-describedby="email-hint"
  aria-invalid={!!errors.email}
  aria-errormessage={errors.email ? 'email-error' : undefined}
/>
{errors.email && <p id="email-error" role="alert">{errors.email}</p>}

<!-- Current state indicators -->
<nav aria-label="Breadcrumb">
  <ol>
    <li><a href="/">Home</a></li>
    <li><a href="/projects">Projects</a></li>
    <li><a href="/projects/alpha" aria-current="page">Project Alpha</a></li>
  </ol>
</nav>

<!-- Expandable sections -->
<button
  aria-expanded={isOpen}
  aria-controls="panel-1"
  onClick={() => setIsOpen(!isOpen)}
>
  {isOpen ? 'Collapse' : 'Expand'} Details
</button>
<div id="panel-1" role="region" hidden={!isOpen}>
  {/* panel content */}
</div>

The aria-live region pattern

This deserves special attention because it’s how you make dynamic content updates perceivable to screen readers. When content inside an aria-live region changes, the screen reader announces the change. This is essential for toast notifications, form validation messages, loading states, and real-time updates.

function SearchResults({ results, isLoading }) {
  return (
    <div>
      <div aria-live="polite" className="sr-only">
        {isLoading
          ? 'Loading results...'
          : `${results.length} results found`}
      </div>
      {/* Visual results rendering */}
    </div>
  );
}

Color and Contrast

WCAG AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (18px+ or 14px+ bold). This is the most commonly failed criterion, and it’s the easiest to fix.

/* BAD: 2.1:1 contrast ratio — fails WCAG AA */
.subtle-text {
  color: #aaaaaa;
  background: #ffffff;
}

/* GOOD: 4.6:1 contrast ratio — passes WCAG AA */
.subtle-text {
  color: #767676;
  background: #ffffff;
}

/* ALSO GOOD: 7.0:1 — passes WCAG AAA */
.body-text {
  color: #333333;
  background: #ffffff;
}

Never use color as the only means of conveying information. Error states should have icons or text in addition to red coloring. Active tabs should have underlines or borders in addition to color changes. Chart data should use patterns or labels in addition to different colors.

<!-- BAD: only color indicates error -->
<input className={hasError ? 'border-red-500' : 'border-gray-300'} />

<!-- GOOD: color + icon + text indicate error -->
<div>
  <input
    className={hasError ? 'border-red-500' : 'border-gray-300'}
    aria-invalid={hasError}
    aria-describedby={hasError ? 'field-error' : undefined}
  />
  {hasError && (
    <p id="field-error" className="text-red-600 flex items-center gap-1 mt-1">
      <AlertCircle className="w-4 h-4" />
      This field is required
    </p>
  )}
</div>

Forms: Where Accessibility Gets Real

Forms are the most interaction-heavy part of most applications and the place where accessibility mistakes cause the most frustration. Here’s a complete accessible form pattern:

function ContactForm() {
  const [errors, setErrors] = useState({});
  const [status, setStatus] = useState('idle');
  const errorSummaryRef = useRef(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);
    const validationErrors = validate(formData);

    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      // Move focus to error summary so screen readers announce it
      errorSummaryRef.current?.focus();
      return;
    }

    setStatus('submitting');
    try {
      await submitForm(formData);
      setStatus('success');
    } catch {
      setStatus('error');
    }
  };

  return (
    <form onSubmit={handleSubmit} noValidate aria-label="Contact form">
      {/* Error summary at the top */}
      {Object.keys(errors).length > 0 && (
        <div
          ref={errorSummaryRef}
          role="alert"
          tabIndex={-1}
          className="p-4 bg-red-50 border border-red-200 rounded mb-6"
        >
          <h2 className="font-bold text-red-800">
            There {Object.keys(errors).length === 1 ? 'is 1 error' : `are ${Object.keys(errors).length} errors`} in this form:
          </h2>
          <ul className="mt-2">
            {Object.entries(errors).map(([field, message]) => (
              <li key={field}>
                <a href={`#${field}`} className="text-red-600 underline">
                  {message}
                </a>
              </li>
            ))}
          </ul>
        </div>
      )}

      {/* Each field with proper labeling */}
      <div className="mb-4">
        <label htmlFor="name" className="block font-medium mb-1">
          Full Name <span aria-hidden="true">*</span>
        </label>
        <input
          id="name"
          name="name"
          type="text"
          required
          aria-required="true"
          aria-invalid={!!errors.name}
          aria-describedby={errors.name ? 'name-error' : undefined}
          className="w-full border rounded px-3 py-2"
        />
        {errors.name && (
          <p id="name-error" className="text-red-600 text-sm mt-1">
            {errors.name}
          </p>
        )}
      </div>

      <button
        type="submit"
        disabled={status === 'submitting'}
        aria-busy={status === 'submitting'}
      >
        {status === 'submitting' ? 'Sending...' : 'Send Message'}
      </button>

      {/* Status announcements */}
      <div aria-live="polite" className="sr-only">
        {status === 'success' && 'Your message has been sent successfully.'}
        {status === 'error' && 'There was a problem sending your message. Please try again.'}
      </div>
    </form>
  );
}

Key patterns in this form: an error summary that receives focus and links to individual fields, aria-invalid and aria-describedby connecting each field to its error message, aria-required for required fields, aria-busy during submission, and a live region for status announcements.

Images and Media

Every <img> element needs an alt attribute. But the content of that attribute depends on the image’s purpose:

<!-- Informative image: describe what's shown -->
<img src="/team-photo.jpg" alt="Harbor Software team at the 2023 company retreat, standing in front of the Islamabad Convention Center" />

<!-- Decorative image: empty alt (not missing, empty) -->
<img src="/wave-divider.svg" alt="" />

<!-- Functional image (inside a link/button): describe the action -->
<a href="/">
  <img src="/logo.svg" alt="Harbor Software — go to homepage" />
</a>

<!-- Complex image: reference a longer description -->
<figure>
  <img src="/architecture-diagram.png" alt="System architecture diagram" aria-describedby="arch-desc" />
  <figcaption id="arch-desc">
    The system consists of three layers: a React frontend communicating via GraphQL with a Node.js API layer, which connects to both a PostgreSQL database and an S3 storage bucket for document uploads.
  </figcaption>
</figure>

Testing Your Accessibility

Automated testing catches about 30-40% of accessibility issues. Manual testing and real-user testing catch the rest. Here’s our testing stack:

Automated (catches the obvious stuff)

// jest-axe for unit/integration tests
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

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

// Playwright for end-to-end accessibility checks
import AxeBuilder from '@axe-core/playwright';

test('dashboard page passes axe', async ({ page }) => {
  await page.goto('/dashboard');
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toEqual([]);
});

Manual testing checklist (run this before every release)

  1. Keyboard-only navigation: Unplug your mouse. Can you reach and operate every interactive element? Can you see where focus is at all times?
  2. Screen reader testing: Test with NVDA (Windows, free), VoiceOver (macOS, built-in), or JAWS. Navigate through main user flows. Are all buttons, links, and form fields announced correctly?
  3. Zoom testing: Zoom to 200% in the browser. Does the layout still work? Is any content hidden or overlapping?
  4. Color contrast: Run the browser’s built-in contrast checker (DevTools > Rendering > Emulate vision deficiencies) or use the axe DevTools extension.
  5. Reduced motion: Enable “prefers-reduced-motion” in your OS settings. Do animations respect this preference?
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Building an Accessible Component Library

Rather than solving accessibility per-component, build accessible primitives that your whole team can reuse. Libraries like Radix UI, Headless UI, and React Aria provide unstyled, fully accessible component primitives that handle the hard parts — focus management, keyboard interaction, ARIA attributes — so you only need to add styling.

// Using Radix UI for an accessible dropdown
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';

function UserMenu({ user }) {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger className="flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-100">
        <img src={user.avatar} alt="" className="w-8 h-8 rounded-full" />
        <span>{user.name}</span>
      </DropdownMenu.Trigger>

      <DropdownMenu.Portal>
        <DropdownMenu.Content className="bg-white shadow-lg rounded-lg p-1 min-w-[200px]" sideOffset={5}>
          <DropdownMenu.Item className="px-3 py-2 rounded cursor-pointer hover:bg-gray-100 outline-none focus:bg-gray-100">
            Profile
          </DropdownMenu.Item>
          <DropdownMenu.Item className="px-3 py-2 rounded cursor-pointer hover:bg-gray-100 outline-none focus:bg-gray-100">
            Settings
          </DropdownMenu.Item>
          <DropdownMenu.Separator className="h-px bg-gray-200 my-1" />
          <DropdownMenu.Item className="px-3 py-2 rounded cursor-pointer hover:bg-gray-100 outline-none focus:bg-gray-100 text-red-600">
            Sign Out
          </DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  );
}

Radix handles keyboard navigation (arrow keys, Enter, Escape, type-ahead), focus management, ARIA attributes, and screen reader announcements. You write zero accessibility code. This is the approach we strongly recommend: stand on the shoulders of libraries that have already solved the hard accessibility problems.

The Business Case

If the moral argument doesn’t move your stakeholders, here are the business arguments:

  • Legal compliance. The ADA, Section 508, and the European Accessibility Act all require accessible digital products. Lawsuits over web accessibility have increased 320% since 2018.
  • Market size. 15% of the global population has a disability. That’s over a billion potential users. Ignoring accessibility is ignoring 15% of your market.
  • SEO benefits. Semantic HTML, alt text, heading hierarchy, and descriptive link text are all ranking factors. Accessible sites tend to rank better.
  • Better UX for everyone. Captions help people in noisy environments. High contrast helps people in bright sunlight. Keyboard navigation helps power users. Accessible design improves the experience for all users, not just those with disabilities.

Making It Sustainable

The hardest part of accessibility isn’t the technical implementation — it’s making it a consistent practice. Here’s how we’ve embedded it into our workflow at Harbor:

  1. ESLint rules: eslint-plugin-jsx-a11y catches common mistakes at the editor level. Missing alt text, missing form labels, incorrect ARIA usage — all caught before commit.
  2. Automated CI checks: axe-core runs on every PR against key pages. PRs that introduce new violations are blocked.
  3. Manual testing rotation: Each sprint, a different developer does the keyboard-only and screen reader testing. This builds team-wide accessibility literacy.
  4. Accessible component library: We built on Radix UI primitives. When developers use our shared components, they get accessibility for free. The hard work is done once and reused everywhere.
  5. Definition of done: A feature isn’t done until it passes keyboard navigation, screen reader announcement, and color contrast checks. This is non-negotiable, like any other quality standard.

Accessibility isn’t something you bolt on at the end. It’s something you build in from the start, the same way you build in security, performance, and error handling. The patterns in this guide aren’t difficult. They just need to become habits.

Leave a comment

Explore
Drag