Implementing Accessibility in Web Applications

·13 min read·Frontend Developmentintermediate

Why investing in accessible design and code is essential for inclusive products and modern teams

A close-up of a keyboard with the Tab key highlighted to symbolize keyboard navigation and focus management in web interfaces

When I started building interfaces, I treated accessibility as a checkbox that lived at the end of a sprint. It was the last thing I would “fix” before shipping, often by slapping on a few ARIA attributes and calling it done. Over time, I learned that accessible products do not emerge from last-minute patches. They emerge from deliberate decisions baked into the architecture: how we structure HTML, how we manage focus, how we design keyboard interactions, and how we handle dynamic content. Accessibility is not a feature; it is the foundation that lets real users, with real assistive technologies, actually use what we build.

In this post, I will share practical patterns for implementing accessibility in web apps, drawn from real-world projects. We will focus on web standards, HTML semantics, keyboard interactions, and dynamic content patterns using JavaScript. I will include working examples you can adapt, discuss tradeoffs, and point to tools that help you move faster without sacrificing correctness. If you have ever wondered whether ARIA is necessary, how to handle modals accessibly, or where to invest effort first, this is for you.

Context: Where accessibility fits in modern web development

Accessibility is now a baseline expectation for web applications. Regulations like the European Accessibility Act (https://digital-strategy.ec.europa.eu/en/policies/european-accessibility-act) and the Web Content Accessibility Guidelines (WCAG https://www.w3.org/WAI/standards-guidelines/wcagh/) shape how teams build for the web. Organizations that do not invest in accessible design risk alienating users and facing compliance issues.

In practice, accessibility spans design, front-end code, and even backend responses. Frontend developers are the primary implementers, but collaboration with designers and QA ensures inclusive experiences. The techniques we use are mostly HTML-first. We prefer semantic tags over generic divs, ensure keyboard users can navigate, and respect user preferences like reduced motion.

Compared to alternatives, the standards-based approach has clear advantages. You get broad support across browsers and assistive technologies. Frameworks like React, Vue, or Svelte do not prevent accessibility, but they can obscure it. A button element still means a button, even when wrapped in a component. If you hide semantics, you hide meaning from assistive tech. The key is to respect the platform and layer frameworks on top, not instead of, native semantics.

Core concepts: Accessibility as a first-class architecture

Semantic HTML is your strongest tool

Semantic HTML communicates structure and intent. A screen reader can tell a user “button, Submit” instead of “clickable.” Navigation landmarks like header, main, nav, and footer help users skip repetitive content. When building forms, use label elements and associate them with inputs using the for attribute and matching id. Use fieldset and legend for grouped controls.

Example of a semantically correct form:

<form aria-labelledby="form-title">
  <h2 id="form-title">Contact us</h2>
  <div class="field">
    <label for="email">Email</label>
    <input type="email" id="email" name="email" required aria-describedby="email-hint" />
    <small id="email-hint">We'll only use this to reply to your message.</small>
  </div>

  <fieldset>
    <legend>Preferred contact method</legend>
    <div>
      <input type="radio" id="contact-email" name="contact-method" value="email" checked />
      <label for="contact-email">Email</label>
    </div>
    <div>
      <input type="radio" id="contact-phone" name="contact-method" value="phone" />
      <label for="contact-phone">Phone</label>
    </div>
  </fieldset>

  <div class="field">
    <label for="message">Message</label>
    <textarea id="message" name="message" required></textarea>
  </div>

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

Notice the following:

  • The label is programmatically associated with the input. Screen readers announce both.
  • aria-describedby points to hint text, providing extra context without breaking the label association.
  • Fieldset and legend group related radio buttons, making the group understandable.

Focus management: keeping users oriented

Keyboard users rely on visible focus. Never remove outline unless you replace it with a clear alternative. Focus should move logically through the page. When opening dialogs, move focus into the dialog and trap it until the dialog closes. When closing, return focus to the element that opened the dialog. This is a core pattern for modals, menus, and drawers.

ARIA: use it wisely

ARIA augments semantics when native HTML is insufficient. It does not change behavior. If you can use a native element, do so. For example, use button instead of div role="button". Common patterns that benefit from ARIA:

  • Live regions: announce dynamic updates with aria-live.
  • Status updates: use role="status" for non-urgent messages.
  • Menus and tabs: use appropriate roles and keyboard interactions.
  • Dialogs: use role="dialog" or the native <dialog> element.

Keyboard interactions: predictable and consistent

Most accessibility issues are keyboard issues. Essential interactions:

  • Tab moves focus forward; Shift + Tab backward.
  • Enter activates buttons and links; Space activates buttons.
  • Arrow keys navigate within widgets (lists, menus, tabs).
  • Escape closes overlays and dismisses popups.

Make custom controls keyboard-friendly. If you build a custom toggle, ensure it has a role, states, and keyboard handlers for Space or Enter.

Motion and preferences

Respect user preferences with CSS media queries:

@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

This avoids dizzying animations for users sensitive to motion.

Practical patterns: real-world code examples

Accessible modal dialog with vanilla JavaScript

Use the native <dialog> element when possible (modern browsers). It handles focus and basic semantics. Below is a practical pattern with a fallback approach.

<button id="openDialog">Open dialog</button>

<dialog id="modal" aria-labelledby="modal-title" aria-describedby="modal-desc">
  <h3 id="modal-title">Example dialog</h3>
  <p id="modal-desc">This dialog traps focus and restores it on close.</p>

  <form method="dialog">
    <button id="confirm">Confirm</button>
    <button type="button" id="cancel">Cancel</button>
  </form>
</dialog>
// Setup references
const openBtn = document.getElementById('openDialog');
const modal = document.getElementById('modal');
const confirmBtn = document.getElementById('confirm');
const cancelBtn = document.getElementById('cancel');

function openModal() {
  // Move focus into the dialog
  modal.showModal();
  confirmBtn.focus();
  // Prevent background scroll
  document.body.style.overflow = 'hidden';
}

function closeModal() {
  modal.close();
  document.body.style.overflow = '';
  // Restore focus to the opener
  openBtn.focus();
}

openBtn.addEventListener('click', openModal);
cancelBtn.addEventListener('click', closeModal);
confirmBtn.addEventListener('click', () => {
  // Perform confirmation action
  closeModal();
});

// Close on backdrop click
modal.addEventListener('click', (e) => {
  const rect = modal.getBoundingClientRect();
  const isInDialog = e.clientX >= rect.left && e.clientX <= rect.right &&
                     e.clientY >= rect.top && e.clientY <= rect.bottom;
  if (!isInDialog) {
    closeModal();
  }
});

This pattern ensures:

  • Focus is trapped in the dialog while open.
  • Focus returns to the trigger on close.
  • Background scroll is disabled.
  • Users can close with Escape or by clicking outside.

Announce dynamic updates with live regions

In apps that update content without a full page reload, use aria-live to inform screen readers of changes. Live regions work well for notifications, search results, and status updates.

<div id="status" role="status" aria-live="polite"></div>
function announce(message) {
  const region = document.getElementById('status');
  // Clear then set to trigger announcement
  region.textContent = '';
  // Small delay helps some screen readers
  setTimeout(() => {
    region.textContent = message;
  }, 50);
}

announce('Saved successfully.');

Use aria-live="polite" for non-urgent updates and aria-live="assertive" sparingly for critical alerts.

Skip links for keyboard users

Provide a way to bypass repetitive navigation. Place a skip link at the top of the page.

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

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

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

The link becomes visible when focused, allowing keyboard users to jump straight to the main content.

Accessible autocomplete

When building an autocomplete, set aria-expanded, manage focus, and announce results. This example shows the pattern without a full component library.

<label for="fruit">Choose a fruit</label>
<input
  id="fruit"
  type="text"
  role="combobox"
  aria-autocomplete="list"
  aria-expanded="false"
  aria-controls="fruit-list"
  aria-haspopup="listbox"
/>
<ul id="fruit-list" role="listbox" hidden>
  <li id="opt-apple" role="option" tabindex="-1">Apple</li>
  <li id="opt-banana" role="option" tabindex="-1">Banana</li>
  <li id="opt-cherry" role="option" tabindex="-1">Cherry</li>
</ul>
const input = document.getElementById('fruit');
const list = document.getElementById('fruit-list');
const options = Array.from(list.querySelectorAll('[role="option"]'));

function showList() {
  list.hidden = false;
  input.setAttribute('aria-expanded', 'true');
}

function hideList() {
  list.hidden = true;
  input.setAttribute('aria-expanded', 'false');
}

function setActive(index) {
  options.forEach((opt, i) => {
    opt.classList.toggle('active', i === index);
    if (i === index) {
      input.setAttribute('aria-activedescendant', opt.id);
      opt.focus();
    }
  });
}

input.addEventListener('input', () => {
  const value = input.value.toLowerCase();
  let visibleCount = 0;
  options.forEach(opt => {
    const match = opt.textContent.toLowerCase().includes(value);
    opt.hidden = !match;
    if (match) visibleCount++;
  });

  if (visibleCount > 0) {
    showList();
    setActive(0);
  } else {
    hideList();
  }
});

input.addEventListener('keydown', (e) => {
  const currentIndex = options.findIndex(opt => opt.classList.contains('active'));
  if (e.key === 'ArrowDown') {
    e.preventDefault();
    const next = Math.min(currentIndex + 1, options.length - 1);
    setActive(next);
  } else if (e.key === 'ArrowUp') {
    e.preventDefault();
    const prev = Math.max(currentIndex - 1, 0);
    setActive(prev);
  } else if (e.key === 'Enter' && currentIndex >= 0) {
    e.preventDefault();
    input.value = options[currentIndex].textContent;
    hideList();
  } else if (e.key === 'Escape') {
    hideList();
  }
});

document.addEventListener('click', (e) => {
  if (!list.contains(e.target) && e.target !== input) {
    hideList();
  }
});

This pattern is not exhaustive but highlights the essentials: roles, ARIA attributes, keyboard navigation, and focus management. For production, consider robust libraries like downshift (https://www.downshift-js.com/) that encapsulate accessibility patterns.

Touch targets and responsive interactions

Ensure interactive targets are large enough to tap. WCAG 2.2 recommends at least 24x24 CSS pixels for target size. For complex UIs, consider:

  • Using padding to increase hit area without changing visual size.
  • Avoiding tiny icon-only buttons without labels.
  • Ensuring focus indicators meet contrast requirements.

Testing and tooling: move fast, don’t break things

Automated checks

Automated tools can catch low-hanging fruit:

Automated checks can detect missing labels, low contrast, and incorrect roles. They cannot judge meaningful link text or complex keyboard behavior. Treat them as guardrails, not guarantees.

Manual testing

The most effective tests are manual:

  • Navigate with the keyboard only. Can you reach and operate every control?
  • Use a screen reader. On macOS, VoiceOver is built-in; on Windows, NVDA is free.
  • Test with zoom up to 400% and reduced motion.
  • Review focus order and visible focus.

Include accessibility in your QA checklist. If a flow is not keyboard-accessible, it is a bug.

Honest evaluation: strengths, weaknesses, and tradeoffs

Strengths

  • Standards-based: Built on HTML, CSS, and JS that work everywhere.
  • Performance: Semantic HTML costs nothing; lightweight compared to heavy UI libraries.
  • Maintainability: Clear roles and labels make code easier to reason about.
  • Inclusivity: Opens your app to a broader audience.

Weaknesses

  • Tooling gaps: Automated tests miss nuanced issues; manual testing is required.
  • Framework complexity: Component libraries can strip semantics; you must be vigilant.
  • Time cost: Doing it right requires planning and testing. It can feel slower at first.
  • Legacy code: Retrofitting accessibility in existing apps can be non-trivial.

When is this approach a good fit?

When might you skip this approach?

  • Never skip accessibility. However, you might choose an existing accessible component library over building from scratch to save time and reduce risk. The strategy changes; the goal does not.

Personal experience: what I learned the hard way

In one project, we built a data dashboard with custom tables and complex filters. Keyboard navigation was fine until we added a modal for column settings. We forgot to trap focus, and screen readers announced content behind the modal. The fix was not just adding a focus trap but ensuring the modal title was announced (aria-labelledby), and focus returned to the column button that opened it. These small details make or break the experience.

Another common mistake is treating ARIA as a quick fix. Early on, I added role="button" to a div and called it done. It looked like a button, but it lacked keyboard activation. The result was confusion for keyboard users. The lesson stuck: start with the right element; if you must build a custom control, implement the expected keyboard interactions and states.

The moment accessibility proved its value was during user testing. A participant using a screen reader navigated our checkout flow without help, because we had labeled inputs, proper headings, and live region updates on validation. That feedback loop is the best motivator. Accessibility is not abstract; it helps real people do real things.

Getting started: workflow and mental models

Project structure

Organize your project with accessibility in mind. Keep components modular and document expected behaviors. A simple structure might look like this:

src/
  components/
    Button/
      Button.tsx
      Button.test.tsx
      Button.stories.tsx
    Modal/
      Modal.tsx
      Modal.test.tsx
    SkipLink/
      SkipLink.tsx
  pages/
    Home.tsx
    Checkout.tsx
  utils/
    focus.ts
    announce.ts
  styles/
    globals.css

Configuration and checks

Add linting and automated tests early. Example package.json scripts:

# Lint for accessibility issues in JSX
npm run lint:a11y

# Run tests with accessibility assertions
npm run test:a11y

# Build and run Lighthouse CI
npm run lighthouse

For linting, configure eslint-plugin-jsx-a11y. For tests, consider jest-axe (https://github.com/nickcolley/jest-axe) to assert that components do not contain critical violations.

Mental model

  1. Use semantic HTML by default.
  2. Enhance with ARIA only when semantics are insufficient.
  3. Manage focus deliberately, especially for overlays and dynamic views.
  4. Announce changes with live regions.
  5. Test with keyboard and a screen reader; automate where possible.

This mental model keeps you grounded. Accessibility is not a checklist; it is a workflow.

Free learning resources

Summary: who should use this and what to expect

If you build web applications—especially in teams—this approach is for you. Accessibility improves usability for everyone and ensures compliance. It is a strong choice for:

  • New projects across frameworks (React, Vue, Svelte).
  • Design systems and component libraries.
  • Public-facing applications in regulated sectors.
  • Teams investing in quality, maintainability, and inclusivity.

You might reconsider if you are prototyping something that will be thrown away, but even then, starting with semantics costs little and builds good habits. Accessibility is not a gate at the end; it is a set of practices that keep your product usable from day one.

Takeaway: Build with HTML first. Keep focus visible and logical. Use ARIA to fill gaps, not to patch poor semantics. Test with real users and tools. Accessibility is not extra work; it is the work of building for the web.