Hub

Accessible Components Hub

View contents

Accessible Web Components Hub: Forms, Navigation, and Interactive Elements

Introduction

Building accessible web components requires understanding both the visual and programmatic aspects of user interfaces. While native HTML elements come with built-in accessibility, custom interactive components need additional consideration to work properly with assistive technologies.

This hub page provides comprehensive guidance on creating accessible forms, navigation menus, dialogs, tabs, and other interactive elements that work for all users, regardless of how they interact with your website.

Why Component Accessibility Matters

The Challenge

Custom Component Problems:
┌─────────────────────────────────────────────────────────┐
│ 97% of home pages have detectable WCAG failures        │
│ Form accessibility is #3 most common failure category  │
│ Custom controls often lack keyboard support            │
│ ARIA misuse is worse than no ARIA at all               │
└─────────────────────────────────────────────────────────┘

Key Principles

Principle Description
Perceivable Users can perceive component state and content
Operable Works with keyboard, mouse, touch, and assistive tech
Understandable Behavior is predictable and labels are clear
Robust Works across browsers and assistive technologies

Accessible Forms

Form Labels

Every form input must have an associated label:

<!-- Method 1: Explicit label association -->
<label for="username">Username</label>
<input type="text" id="username" name="username">

<!-- Method 2: Implicit label (wrapping) -->
<label>
  Email Address
  <input type="email" name="email">
</label>

<!-- Method 3: aria-labelledby (for complex cases) -->
<span id="label-name">Full Name</span>
<span id="hint-name">As shown on your ID</span>
<input type="text" aria-labelledby="label-name hint-name">

Required Fields

<!-- Indicate required fields properly -->
<label for="email">
  Email Address
  <span aria-hidden="true">*</span>
</label>
<input type="email" id="email" name="email"
       required aria-required="true">

<!-- Legend explaining asterisk -->
<p aria-hidden="true">* Required fields</p>

Error Handling

<!-- Accessible error messages -->
<label for="password">Password</label>
<input type="password" id="password" name="password"
       aria-describedby="password-error"
       aria-invalid="true">
<span id="password-error" role="alert">
  Password must be at least 8 characters
</span>

Form Fieldsets

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

  <label for="street">Street Address</label>
  <input type="text" id="street" name="street">

  <label for="city">City</label>
  <input type="text" id="city" name="city">
</fieldset>

Complete Accessible Form Example

<form aria-labelledby="form-title" novalidate>
  <h2 id="form-title">Contact Us</h2>

  <div class="form-group">
    <label for="name">
      Full Name
      <span class="required" aria-hidden="true">*</span>
    </label>
    <input type="text" id="name" name="name"
           required aria-required="true"
           autocomplete="name">
  </div>

  <div class="form-group">
    <label for="email">
      Email
      <span class="required" aria-hidden="true">*</span>
    </label>
    <input type="email" id="email" name="email"
           required aria-required="true"
           autocomplete="email">
  </div>

  <div class="form-group">
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5"></textarea>
  </div>

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

Accessible Navigation

<nav aria-label="Main">
  <ul role="list">
    <li><a href="/" aria-current="page">Home</a></li>
    <li><a href="/products">Products</a></li>
    <li><a href="/about">About</a></li>
    <li><a href="/contact">Contact</a></li>
  </ul>
</nav>
<nav aria-label="Main">
  <ul role="list">
    <li>
      <button aria-expanded="false" aria-haspopup="true"
              aria-controls="products-menu">
        Products
        <span aria-hidden="true"></span>
      </button>
      <ul id="products-menu" role="menu" hidden>
        <li role="none">
          <a role="menuitem" href="/software">Software</a>
        </li>
        <li role="none">
          <a role="menuitem" href="/hardware">Hardware</a>
        </li>
      </ul>
    </li>
  </ul>
</nav>

Keyboard Navigation Pattern

// Dropdown keyboard support
const dropdown = document.querySelector('[aria-haspopup]');
const menu = document.querySelector('[role="menu"]');
const items = menu.querySelectorAll('[role="menuitem"]');

dropdown.addEventListener('keydown', (e) => {
  switch(e.key) {
    case 'ArrowDown':
    case 'Enter':
    case ' ':
      e.preventDefault();
      menu.hidden = false;
      dropdown.setAttribute('aria-expanded', 'true');
      items[0].focus();
      break;
  }
});

menu.addEventListener('keydown', (e) => {
  const current = document.activeElement;
  const index = Array.from(items).indexOf(current);

  switch(e.key) {
    case 'ArrowDown':
      e.preventDefault();
      items[(index + 1) % items.length].focus();
      break;
    case 'ArrowUp':
      e.preventDefault();
      items[(index - 1 + items.length) % items.length].focus();
      break;
    case 'Escape':
      menu.hidden = true;
      dropdown.setAttribute('aria-expanded', 'false');
      dropdown.focus();
      break;
  }
});
<nav aria-label="Breadcrumb">
  <ol>
    <li><a href="/">Home</a></li>
    <li><a href="/products">Products</a></li>
    <li><a href="/products/software" aria-current="page">Software</a></li>
  </ol>
</nav>
<!-- Place at the very beginning of body -->
<a href="#main-content" class="skip-link">
  Skip to main content
</a>

<!-- CSS for skip link -->
<style>
.skip-link {
  position: absolute;
  left: -10000px;
  top: auto;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.skip-link:focus {
  position: fixed;
  top: 10px;
  left: 10px;
  width: auto;
  height: auto;
  padding: 1rem;
  background: #000;
  color: #fff;
  z-index: 10000;
}
</style>

Accessible Dialogs/Modals

<div id="modal" role="dialog" aria-modal="true"
     aria-labelledby="modal-title"
     aria-describedby="modal-description"
     hidden>
  <div class="modal-content">
    <h2 id="modal-title">Confirm Delete</h2>
    <p id="modal-description">
      Are you sure you want to delete this item?
      This action cannot be undone.
    </p>
    <div class="modal-actions">
      <button type="button" class="btn-secondary"
              data-dismiss="modal">Cancel</button>
      <button type="button" class="btn-danger">Delete</button>
    </div>
    <button type="button" class="modal-close"
            aria-label="Close dialog">×</button>
  </div>
</div>

Focus Management

class AccessibleModal {
  constructor(modalElement) {
    this.modal = modalElement;
    this.focusableElements = this.modal.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;
  }

  open() {
    this.previouslyFocused = document.activeElement;
    this.modal.hidden = false;
    this.firstFocusable.focus();
    document.body.classList.add('modal-open');
    this.trapFocus();
  }

  close() {
    this.modal.hidden = true;
    document.body.classList.remove('modal-open');
    this.previouslyFocused?.focus();
  }

  trapFocus() {
    this.modal.addEventListener('keydown', (e) => {
      if (e.key === 'Tab') {
        if (e.shiftKey && document.activeElement === this.firstFocusable) {
          e.preventDefault();
          this.lastFocusable.focus();
        } else if (!e.shiftKey && document.activeElement === this.lastFocusable) {
          e.preventDefault();
          this.firstFocusable.focus();
        }
      }
      if (e.key === 'Escape') {
        this.close();
      }
    });
  }
}

Accessible Tabs

Tab Panel Pattern

<div class="tabs">
  <div role="tablist" aria-label="Product Information">
    <button role="tab" id="tab-1" aria-selected="true"
            aria-controls="panel-1" tabindex="0">
      Description
    </button>
    <button role="tab" id="tab-2" aria-selected="false"
            aria-controls="panel-2" tabindex="-1">
      Specifications
    </button>
    <button role="tab" id="tab-3" aria-selected="false"
            aria-controls="panel-3" tabindex="-1">
      Reviews
    </button>
  </div>

  <div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
    <h3>Product Description</h3>
    <p>...</p>
  </div>
  <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
    <h3>Technical Specifications</h3>
    <p>...</p>
  </div>
  <div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
    <h3>Customer Reviews</h3>
    <p>...</p>
  </div>
</div>

Tab Keyboard Navigation

// Arrow key navigation between tabs
const tablist = document.querySelector('[role="tablist"]');
const tabs = tablist.querySelectorAll('[role="tab"]');

tablist.addEventListener('keydown', (e) => {
  const currentTab = document.activeElement;
  const index = Array.from(tabs).indexOf(currentTab);
  let newIndex;

  switch(e.key) {
    case 'ArrowRight':
      newIndex = (index + 1) % tabs.length;
      break;
    case 'ArrowLeft':
      newIndex = (index - 1 + tabs.length) % tabs.length;
      break;
    case 'Home':
      newIndex = 0;
      break;
    case 'End':
      newIndex = tabs.length - 1;
      break;
    default:
      return;
  }

  e.preventDefault();
  activateTab(tabs[newIndex]);
});

function activateTab(tab) {
  // Deactivate all tabs
  tabs.forEach(t => {
    t.setAttribute('aria-selected', 'false');
    t.setAttribute('tabindex', '-1');
    document.getElementById(t.getAttribute('aria-controls')).hidden = true;
  });

  // Activate selected tab
  tab.setAttribute('aria-selected', 'true');
  tab.setAttribute('tabindex', '0');
  tab.focus();
  document.getElementById(tab.getAttribute('aria-controls')).hidden = false;
}

Accessible Accordions

<div class="accordion">
  <h3>
    <button aria-expanded="false" aria-controls="section1-content"
            id="section1-header" class="accordion-trigger">
      Section 1
      <span aria-hidden="true">+</span>
    </button>
  </h3>
  <div id="section1-content" role="region"
       aria-labelledby="section1-header" hidden>
    <p>Content for section 1...</p>
  </div>

  <h3>
    <button aria-expanded="false" aria-controls="section2-content"
            id="section2-header" class="accordion-trigger">
      Section 2
      <span aria-hidden="true">+</span>
    </button>
  </h3>
  <div id="section2-content" role="region"
       aria-labelledby="section2-header" hidden>
    <p>Content for section 2...</p>
  </div>
</div>

Accessible Buttons

<!-- Use button for actions -->
<button type="button" onclick="submitForm()">Submit</button>
<button type="button" onclick="deleteItem()">Delete</button>

<!-- Use links for navigation -->
<a href="/products">View Products</a>
<a href="/about">About Us</a>

<!-- Icon buttons need accessible names -->
<button type="button" aria-label="Close">
  <svg aria-hidden="true">...</svg>
</button>

<button type="button" aria-label="Search">
  <span class="icon-search" aria-hidden="true"></span>
</button>

Toggle Buttons

<!-- Toggle button pattern -->
<button type="button" aria-pressed="false"
        onclick="toggleDarkMode(this)">
  <span class="icon" aria-hidden="true">🌙</span>
  Dark Mode
</button>

<script>
function toggleDarkMode(button) {
  const pressed = button.getAttribute('aria-pressed') === 'true';
  button.setAttribute('aria-pressed', !pressed);
  document.body.classList.toggle('dark-mode');
}
</script>

Focus Management

Visible Focus Indicators

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

/* High contrast focus for dark backgrounds */
.dark-theme :focus {
  outline-color: #fff;
  box-shadow: 0 0 0 2px #005fcc;
}

/* Custom focus for buttons */
button:focus {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

/* Never just remove focus */
/* BAD: :focus { outline: none; } */

Focus Order

<!-- Logical reading order -->
<header>...</header>
<nav>...</nav>
<main>
  <h1>...</h1>
  <!-- Content in logical order -->
</main>
<aside>...</aside>
<footer>...</footer>

<!-- Use tabindex sparingly -->
<!-- tabindex="0" - adds to tab order -->
<!-- tabindex="-1" - programmatically focusable only -->
<!-- Avoid tabindex > 0 -->

Testing Components

Automated Testing

// Jest + axe-core example
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

describe('Accessible Form', () => {
  it('should have no accessibility violations', async () => {
    const { container } = render(<ContactForm />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

Manual Testing Checklist

  • [ ] All interactive elements are keyboard accessible
  • [ ] Focus order is logical
  • [ ] Focus indicators are visible
  • [ ] Screen reader announces correctly
  • [ ] ARIA attributes are valid
  • [ ] Color is not the only indicator

Form Accessibility

Focus & Navigation


References

  1. W3C - WAI-ARIA Authoring Practices
  2. W3C - Forms Tutorial
  3. MDN - ARIA Roles
  4. WebAIM - Accessible Forms
  5. A11Y Project - Resources
  6. Deque - Code Library

Related articles

In the same category

Hub

Wcag Compliance Hub

Web accessibility ensures that websites and applications can be used by everyone, including people with disabilities

Introduction

Form Labels Explained

Form labels are text descriptions that identify the purpose of form fields

Other topics

Hub

Basic Seo Fundamentals Hub

Every successful website is built on a foundation of solid SEO fundamentals