A
Aghyad Alghazawi
1min read

Essential accessibility patterns and techniques for building inclusive web applications.

Web Accessibility: Essential Patterns for Inclusive Design

Web accessibility ensures your applications work for everyone, including the 1.3 billion people worldwide with disabilities. This guide covers essential patterns for building truly accessible web experiences.

Table of Contents

  1. Semantic HTML Foundation
  2. Form Accessibility
  3. ARIA Implementation
  4. Keyboard Navigation
  5. Visual Design
  6. Dynamic Content
  7. Testing & Validation

Semantic HTML Foundation

Document Structure

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Page Title - Site Name</title>
  </head>
  <body>
    <a href="#main-content" class="skip-link">Skip to main content</a>

    <header>
      <nav aria-label="Main navigation">
        <ul>
          <li><a href="/">Home</a></li>
          <li><a href="/about">About</a></li>
          <li><a href="/contact">Contact</a></li>
        </ul>
      </nav>
    </header>

    <main id="main-content">
      <h1>Page Heading</h1>
      <section aria-labelledby="section-heading">
        <h2 id="section-heading">Section Title</h2>
        <!-- Content -->
      </section>
    </main>

    <footer>
      <!-- Footer content -->
    </footer>
  </body>
</html>

Heading Hierarchy

<!-- Proper heading structure -->
<h1>Main Page Title</h1>
<h2>Major Section</h2>
<h3>Subsection</h3>
<h3>Another Subsection</h3>
<h2>Another Major Section</h2>

<!-- Multiple articles -->
<article>
  <h1>Article Title</h1>
  <h2>Article Section</h2>
</article>

Image Accessibility

<!-- Informative images -->
<img src="sales-chart.png" alt="Sales increased 40% from Q1 to Q2 2024" />

<!-- Functional images -->
<button>
  <img src="search-icon.svg" alt="Search" />
</button>

<!-- Decorative images -->
<img src="decoration.jpg" alt="" role="presentation" />

<!-- Complex images -->
<figure>
  <img src="complex-chart.png" alt="Quarterly revenue breakdown" />
  <figcaption>Q1: $2.1M, Q2: $2.8M, Q3: $3.2M, Q4: $2.9M</figcaption>
</figure>

Form Accessibility

Basic Form Structure

<form novalidate>
  <fieldset>
    <legend>Personal Information</legend>

    <div class="form-group">
      <label for="firstName">First Name</label>
      <input
        type="text"
        id="firstName"
        name="firstName"
        required
        aria-describedby="firstName-help"
        autocomplete="given-name"
      />
      <div id="firstName-help" class="help-text">
        Enter your legal first name
      </div>
    </div>

    <div class="form-group">
      <label for="email">Email Address</label>
      <input
        type="email"
        id="email"
        name="email"
        required
        aria-describedby="email-help email-error"
        autocomplete="email"
      />
      <div id="email-help" class="help-text">
        We'll use this to send you updates
      </div>
      <div id="email-error" class="error-message" role="alert" hidden>
        Please enter a valid email address
      </div>
    </div>
  </fieldset>

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

Form Validation

class AccessibleFormValidator {
  constructor(form) {
    this.form = form;
    this.fields = form.querySelectorAll("input, select, textarea");
    this.init();
  }

  init() {
    this.form.addEventListener("submit", (e) => this.handleSubmit(e));
    this.fields.forEach((field) => {
      field.addEventListener("blur", () => this.validateField(field));
      field.addEventListener("input", () => this.clearErrors(field));
    });
  }

  validateField(field) {
    const value = field.value.trim();
    const isRequired = field.hasAttribute("required");
    let errorMessage = "";

    if (isRequired && !value) {
      errorMessage = `${this.getFieldLabel(field)} is required`;
    } else if (field.type === "email" && value && !this.isValidEmail(value)) {
      errorMessage = "Please enter a valid email address";
    }

    this.setFieldError(field, errorMessage);
    return !errorMessage;
  }

  setFieldError(field, message) {
    const errorElement = document.getElementById(`${field.id}-error`);

    if (message) {
      field.setAttribute("aria-invalid", "true");
      if (errorElement) {
        errorElement.textContent = message;
        errorElement.hidden = false;
      }
    } else {
      field.removeAttribute("aria-invalid");
      if (errorElement) {
        errorElement.hidden = true;
      }
    }
  }

  handleSubmit(e) {
    e.preventDefault();
    const isValid = Array.from(this.fields).every((field) =>
      this.validateField(field),
    );

    if (isValid) {
      // Submit form
      this.form.submit();
    } else {
      // Focus first invalid field
      const firstInvalid = this.form.querySelector("[aria-invalid='true']");
      if (firstInvalid) {
        firstInvalid.focus();
      }
    }
  }
}

Custom Select Component

class AccessibleSelect {
  constructor(element) {
    this.select = element;
    this.button = element.querySelector("[role='combobox']");
    this.listbox = element.querySelector("[role='listbox']");
    this.options = element.querySelectorAll("[role='option']");
    this.selectedIndex = 0;
    this.isOpen = false;

    this.init();
  }

  init() {
    this.button.addEventListener("click", () => this.toggle());
    this.button.addEventListener("keydown", (e) => this.handleButtonKeydown(e));

    this.options.forEach((option, index) => {
      option.addEventListener("click", () => this.selectOption(index));
    });
  }

  toggle() {
    this.isOpen = !this.isOpen;
    this.button.setAttribute("aria-expanded", this.isOpen);
    this.listbox.hidden = !this.isOpen;

    if (this.isOpen) {
      this.options[this.selectedIndex].focus();
    }
  }

  selectOption(index) {
    this.selectedIndex = index;
    const selectedOption = this.options[index];

    this.button.textContent = selectedOption.textContent;
    this.button.setAttribute("aria-activedescendant", selectedOption.id);

    this.options.forEach((option, i) => {
      option.setAttribute("aria-selected", i === index);
    });

    this.toggle();
    this.button.focus();
  }

  handleButtonKeydown(e) {
    switch (e.key) {
      case "Enter":
      case " ":
        e.preventDefault();
        this.toggle();
        break;
      case "ArrowDown":
        e.preventDefault();
        if (!this.isOpen) {
          this.toggle();
        } else {
          this.moveSelection(1);
        }
        break;
      case "ArrowUp":
        e.preventDefault();
        if (!this.isOpen) {
          this.toggle();
        } else {
          this.moveSelection(-1);
        }
        break;
      case "Escape":
        if (this.isOpen) {
          this.toggle();
        }
        break;
    }
  }
}

ARIA Implementation

Live Regions

<!-- Status messages -->
<div aria-live="polite" aria-atomic="true" class="sr-only" id="status">
  <!-- Dynamic status messages appear here -->
</div>

<!-- Urgent alerts -->
<div aria-live="assertive" aria-atomic="true" class="sr-only" id="alerts">
  <!-- Critical alerts appear here -->
</div>

<!-- Usage in JavaScript -->
<script>
  function announceStatus(message) {
    const status = document.getElementById("status");
    status.textContent = message;
  }

  function announceAlert(message) {
    const alerts = document.getElementById("alerts");
    alerts.textContent = message;
  }

  // Examples
  announceStatus("Form saved successfully");
  announceAlert("Connection lost. Please check your internet connection.");
</script>

Modal Dialog

<div
  class="modal-overlay"
  id="modal"
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
  hidden
>
  <div class="modal-content">
    <header class="modal-header">
      <h2 id="modal-title">Confirm Action</h2>
      <button class="modal-close" aria-label="Close dialog">&times;</button>
    </header>

    <div class="modal-body">
      <p>Are you sure you want to delete this item?</p>
    </div>

    <footer class="modal-footer">
      <button class="btn-secondary">Cancel</button>
      <button class="btn-danger">Delete</button>
    </footer>
  </div>
</div>
class AccessibleModal {
  constructor(modalElement) {
    this.modal = modalElement;
    this.closeButton = modalElement.querySelector(".modal-close");
    this.focusableElements = modalElement.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
    );
    this.firstFocusable = this.focusableElements[0];
    this.lastFocusable =
      this.focusableElements[this.focusableElements.length - 1];
    this.previouslyFocused = null;

    this.init();
  }

  init() {
    this.closeButton.addEventListener("click", () => this.close());
    this.modal.addEventListener("keydown", (e) => this.handleKeydown(e));
  }

  open() {
    this.previouslyFocused = document.activeElement;
    this.modal.hidden = false;
    this.firstFocusable.focus();
    document.body.style.overflow = "hidden";
  }

  close() {
    this.modal.hidden = true;
    document.body.style.overflow = "";
    if (this.previouslyFocused) {
      this.previouslyFocused.focus();
    }
  }

  handleKeydown(e) {
    if (e.key === "Escape") {
      this.close();
      return;
    }

    if (e.key === "Tab") {
      if (e.shiftKey) {
        if (document.activeElement === this.firstFocusable) {
          e.preventDefault();
          this.lastFocusable.focus();
        }
      } else {
        if (document.activeElement === this.lastFocusable) {
          e.preventDefault();
          this.firstFocusable.focus();
        }
      }
    }
  }
}

Accordion Component

<div class="accordion">
  <div class="accordion-item">
    <h3>
      <button
        class="accordion-trigger"
        aria-expanded="false"
        aria-controls="panel-1"
        id="trigger-1"
      >
        Section 1 Title
      </button>
    </h3>
    <div
      class="accordion-panel"
      id="panel-1"
      aria-labelledby="trigger-1"
      hidden
    >
      <p>Section 1 content goes here.</p>
    </div>
  </div>
</div>
class AccessibleAccordion {
  constructor(accordion) {
    this.accordion = accordion;
    this.triggers = accordion.querySelectorAll(".accordion-trigger");
    this.init();
  }

  init() {
    this.triggers.forEach((trigger, index) => {
      trigger.addEventListener("click", () => this.toggle(index));
      trigger.addEventListener("keydown", (e) => this.handleKeydown(e, index));
    });
  }

  toggle(index) {
    const trigger = this.triggers[index];
    const panel = document.getElementById(
      trigger.getAttribute("aria-controls"),
    );
    const isExpanded = trigger.getAttribute("aria-expanded") === "true";

    trigger.setAttribute("aria-expanded", !isExpanded);
    panel.hidden = isExpanded;
  }

  handleKeydown(e, index) {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        this.focusNext(index);
        break;
      case "ArrowUp":
        e.preventDefault();
        this.focusPrevious(index);
        break;
      case "Home":
        e.preventDefault();
        this.triggers[0].focus();
        break;
      case "End":
        e.preventDefault();
        this.triggers[this.triggers.length - 1].focus();
        break;
    }
  }

  focusNext(currentIndex) {
    const nextIndex = (currentIndex + 1) % this.triggers.length;
    this.triggers[nextIndex].focus();
  }

  focusPrevious(currentIndex) {
    const prevIndex =
      currentIndex === 0 ? this.triggers.length - 1 : currentIndex - 1;
    this.triggers[prevIndex].focus();
  }
}

Keyboard Navigation

Focus Management

/* Visible focus indicators */
:focus {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}

/* Skip link styles */
.skip-link {
  position: absolute;
  top: -40px;
  left: 6px;
  background: #000;
  color: #fff;
  padding: 8px;
  text-decoration: none;
  z-index: 1000;
}

.skip-link:focus {
  top: 6px;
}

/* Screen reader only content */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Roving Tabindex

class RovingTabindex {
  constructor(container) {
    this.container = container;
    this.items = container.querySelectorAll(
      "[role='tab'], [role='menuitem'], [role='option']",
    );
    this.currentIndex = 0;
    this.init();
  }

  init() {
    this.items.forEach((item, index) => {
      item.tabIndex = index === 0 ? 0 : -1;
      item.addEventListener("keydown", (e) => this.handleKeydown(e, index));
      item.addEventListener("focus", () => this.setCurrentIndex(index));
    });
  }

  handleKeydown(e, index) {
    let newIndex = index;

    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        e.preventDefault();
        newIndex = (index + 1) % this.items.length;
        break;
      case "ArrowLeft":
      case "ArrowUp":
        e.preventDefault();
        newIndex = index === 0 ? this.items.length - 1 : index - 1;
        break;
      case "Home":
        e.preventDefault();
        newIndex = 0;
        break;
      case "End":
        e.preventDefault();
        newIndex = this.items.length - 1;
        break;
      default:
        return;
    }

    this.focusItem(newIndex);
  }

  focusItem(index) {
    this.items[this.currentIndex].tabIndex = -1;
    this.items[index].tabIndex = 0;
    this.items[index].focus();
    this.currentIndex = index;
  }

  setCurrentIndex(index) {
    this.currentIndex = index;
  }
}

Visual Design

Color and Contrast

/* WCAG AA compliant color combinations */
:root {
  --primary-color: #005fcc; /* 4.5:1 contrast ratio on white */
  --secondary-color: #6c757d;
  --success-color: #198754;
  --warning-color: #fd7e14;
  --error-color: #dc3545;

  /* Text colors */
  --text-primary: #212529; /* 16.75:1 contrast ratio on white */
  --text-secondary: #6c757d; /* 4.5:1 contrast ratio on white */
}

/* Don't rely solely on color */
.status-indicator {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
}

.status-indicator::before {
  content: "";
  width: 8px;
  height: 8px;
  border-radius: 50%;
}

.status-success {
  color: var(--success-color);
}

.status-success::before {
  background-color: var(--success-color);
}

.status-error {
  color: var(--error-color);
}

.status-error::before {
  background-color: var(--error-color);
}

Responsive Typography

/* Scalable text that respects user preferences */
html {
  font-size: 100%; /* Respects user's browser settings */
}

body {
  font-family:
    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  line-height: 1.5;
  color: var(--text-primary);
}

/* Minimum touch target size: 44px */
button,
.btn {
  min-height: 44px;
  min-width: 44px;
  padding: 0.5rem 1rem;
}

/* Responsive text scaling */
h1 {
  font-size: clamp(1.5rem, 4vw, 2.5rem);
}
h2 {
  font-size: clamp(1.25rem, 3vw, 2rem);
}
h3 {
  font-size: clamp(1.125rem, 2.5vw, 1.5rem);
}

/* Ensure sufficient spacing */
p,
ul,
ol {
  margin-bottom: 1rem;
}

li {
  margin-bottom: 0.25rem;
}

Dynamic Content

Loading States

<div class="loading-container" aria-live="polite" aria-busy="true">
  <div class="spinner" aria-hidden="true"></div>
  <span class="sr-only">Loading content, please wait...</span>
</div>
class AccessibleLoader {
  constructor(container) {
    this.container = container;
    this.loadingMessage = container.querySelector(".sr-only");
  }

  show(message = "Loading content, please wait...") {
    this.container.setAttribute("aria-busy", "true");
    this.loadingMessage.textContent = message;
    this.container.hidden = false;
  }

  hide() {
    this.container.setAttribute("aria-busy", "false");
    this.container.hidden = true;
  }

  updateMessage(message) {
    this.loadingMessage.textContent = message;
  }
}

Error Handling

class AccessibleErrorHandler {
  constructor() {
    this.errorContainer = document.getElementById("error-announcements");
  }

  announceError(message, type = "error") {
    const errorElement = document.createElement("div");
    errorElement.setAttribute("role", "alert");
    errorElement.className = `error-message error-${type}`;
    errorElement.textContent = message;

    this.errorContainer.appendChild(errorElement);

    // Remove after announcement
    setTimeout(() => {
      errorElement.remove();
    }, 5000);
  }

  clearErrors() {
    this.errorContainer.innerHTML = "";
  }
}

// Usage
const errorHandler = new AccessibleErrorHandler();
errorHandler.announceError("Failed to save changes. Please try again.");

Testing & Validation

Automated Testing

// Using axe-core for accessibility testing
import { axe, toHaveNoViolations } from "jest-axe";

expect.extend(toHaveNoViolations);

describe("Accessibility Tests", () => {
  test("should not have accessibility violations", async () => {
    const { container } = render(<MyComponent />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  test("should have proper focus management", () => {
    const { getByRole } = render(<Modal />);
    const modal = getByRole("dialog");

    // Test focus trap
    fireEvent.keyDown(modal, { key: "Tab" });
    expect(document.activeElement).toBe(getByRole("button", { name: "Close" }));
  });
});

Manual Testing Checklist

class AccessibilityChecker {
  static runChecklist() {
    const checks = [
      {
        name: "Keyboard Navigation",
        test: () => {
          console.log(
            "✓ Can you navigate the entire page using only the keyboard?",
          );
          console.log("✓ Are focus indicators visible and clear?");
          console.log("✓ Is the tab order logical?");
        },
      },
      {
        name: "Screen Reader",
        test: () => {
          console.log("✓ Does content make sense when read aloud?");
          console.log("✓ Are headings properly structured?");
          console.log("✓ Are form labels clear and descriptive?");
        },
      },
      {
        name: "Visual Design",
        test: () => {
          console.log("✓ Is text contrast sufficient (4.5:1 minimum)?");
          console.log("✓ Does content work at 200% zoom?");
          console.log("✓ Are touch targets at least 44px?");
        },
      },
    ];

    checks.forEach((check) => {
      console.group(check.name);
      check.test();
      console.groupEnd();
    });
  }
}

Best Practices

Development Guidelines

  • Semantic HTML First: Use proper HTML elements before adding ARIA
  • Progressive Enhancement: Ensure basic functionality works without JavaScript
  • Test Early and Often: Include accessibility testing in your development workflow
  • Real User Testing: Test with actual users who rely on assistive technologies

Common Patterns

<!-- Button vs Link -->
<button onclick="saveData()">Save</button>
<!-- Actions -->
<a href="/profile">View Profile</a>
<!-- Navigation -->

<!-- Proper labeling -->
<label for="search">Search</label>
<input type="search" id="search" placeholder="Enter keywords..." />

<!-- Group related content -->
<fieldset>
  <legend>Shipping Address</legend>
  <!-- Address fields -->
</fieldset>

Performance Considerations

/* Respect user preferences */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg-color: #1a1a1a;
    --text-color: #ffffff;
  }
}

Conclusion

Accessibility is not a feature to be added later—it's a fundamental aspect of good web development. By following these patterns and making accessibility a core part of your development process, you create better experiences for all users.

Remember: accessibility benefits everyone, not just users with disabilities. Clear navigation, good contrast, and logical structure improve usability for all users, especially in challenging environments like bright sunlight or noisy spaces.

Start with semantic HTML, test with real users, and iterate based on feedback. Accessibility is a journey, not a destination.