All posts
18 Sep 2026

Beyond the Basics: Building Accessible, Keyboard-Ready Complex Components from Scratch

A practical engineering guide detailing how to manage focus traps, ARIA attributes, and keyboard navigation for custom widgets like comboboxes and modals.

Beyond the Basics: Building Accessible, Keyboard-Ready Complex Components from Scratch

When we transition from building static web pages to dynamic, application-like interfaces, our UI components grow exponentially in complexity. Accordions, tabs, comboboxes, and modals are standard requirements in modern web development. However, relying solely on default HTML elements or poorly constructed JavaScript frameworks often results in a fractured experience for screen reader users and keyboard-only operators.

Accessibility (a11y) is not an afterthought or an overlay; it is a core pillar of resilient engineering. In this guide, we will unpack the architectural patterns required to build custom, highly accessible complex UI components from scratch—focusing on modals and comboboxes—while examining focus management, ARIA contracts, and keyboard event handling.


The Accessibility Contract: ARIA and Semantics

Before writing a single line of JavaScript, you must establish the semantic contract. Assistive technologies (AT) rely on the Accessibility Tree, which is generated from the DOM. When using native elements like <button> or <select>, the browser does the heavy lifting. When building custom components, you must explicitly define roles, states, and properties using ARIA (Accessible Rich Internet Applications) attributes.

Core Rules of ARIA

  1. First Rule of ARIA: Don’t use ARIA if you can use native HTML. If a native <button> or <dialog> fits your use case, use it.
  2. Second Rule of ARIA: Do not change native semantics unless necessary (e.g., don’t turn a button into a presentation role without a valid reason).
  3. Third Rule of ARIA: All interactive ARIA controls must be usable via the keyboard.
  4. Fourth Rule of ARIA: Do not use aria-hidden="true" on focusable elements.

Component 1: The Accessible Modal Dialog

A modal dialog interrupts the user’s workflow to demand interaction. A truly accessible modal must satisfy three strict requirements:

  1. Focus Trap: Focus must remain trapped inside the modal until it is closed.
  2. Inert Background: Everything outside the modal must be hidden from screen readers and rendered inert.
  3. Escape Hatch: Pressing the Escape key must close the modal, and focus must return to the element that triggered it.

HTML Structure

html
<button id="open-modal" aria-haspopup="dialog">Open Settings</button>

<div 
  id="settings-modal" 
  class="modal-overlay" 
  role="dialog" 
  aria-modal="true" 
  aria-labelledby="modal-title"
  hidden
>
  <div class="modal-content">
    <h2 id="modal-title">Account Settings</h2>
    <p>Update your preferences below.</p>
    <button id="close-modal">Close</button>
  </div>
</div>

Implementing the Focus Trap in Vanilla JavaScript

A focus trap works by listening for Tab key events and cycling the focus through all focusable elements within the container.

class AccessibleModal {
  constructor(modalElement, openButtonElement) {
    this.modal = modalElement;
    this.openButton = openButtonElement;
    this.closeButton = this.modal.querySelector('#close-modal');
    this.previouslyFocusedElement = null;

    this.bindEvents();
  }

  bindEvents() {
    this.openButton.addEventListener('click', () => this.open());
    this.closeButton.addEventListener('click', () => this.close());
    this.modal.addEventListener('keydown', (e) => this.handleKeyDown(e));
  }

  open() {
    this.previouslyFocusedElement = document.activeElement;
    this.modal.removeAttribute('hidden');
    document.body.style.overflow = 'hidden';

    // Gather all focusable elements inside the modal
    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];

    // Set initial focus
    this.firstFocusable.focus();
  }

  close() {
    this.modal.setAttribute('hidden', '');
    document.body.style.overflow = '';
    
    if (this.previouslyFocusedElement) {
      this.previouslyFocusedElement.focus();
    }
  }

  handleKeyDown(e) {
    if (e.key === 'Escape') {
      this.close();
      return;
    }

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

// Initialization
const modalEl = document.getElementById('settings-modal');
const openBtn = document.getElementById('open-modal');
new AccessibleModal(modalEl, openBtn);

Component 2: The Accessible Combobox / Dropdown

Comboboxes (autocomplete or custom select widgets) are notoriously tricky. They combine an input field with a popup listbox. According to the WAI-ARIA Authoring Practices Guide (APG), a combobox requires precise management of aria-expanded, aria-activedescendant, and keyboard navigation patterns.

The ARIA Contract for Comboboxes

  • The input element must have role="combobox".
  • aria-expanded toggles between true and false.
  • aria-controls points to the ID of the listbox popup.
  • The popup listbox must have role="listbox", and its items must have role="option".

HTML Markup

<div class="combobox-wrapper">
  <label for="fruit-input">Choose a fruit</label>
  <input 
    id="fruit-input"
    type="text"
    role="combobox"
    aria-expanded="false"
    aria-autocomplete="list"
    aria-controls="fruit-listbox"
    autocomplete="off"
  />
  <ul id="fruit-listbox" role="listbox" hidden>
    <!-- Dynamically populated options -->
  </ul>
</div>

Managing State and Keyboard Events

To make a custom dropdown completely keyboard accessible, you must intercept specific arrow keys and manage option selection states without losing focus from the input field.

class AccessibleCombobox {
  constructor(containerEl, options) {
    this.container = containerEl;
    this.input = this.container.querySelector('input');
    this.listbox = this.container.querySelector('ul');
    this.options = options;
    this.activeIndex = -1;

    this.init();
  }

  init() {
    this.input.addEventListener('input', (e) => this.onInput(e));
    this.input.addEventListener('keydown', (e) => this.onKeyDown(e));
    this.listbox.addEventListener('click', (e) => this.onOptionClick(e));
    document.addEventListener('click', (e) => {
      if (!this.container.contains(e.target)) this.close();
    });
  }

  open() {
    this.listbox.removeAttribute('hidden');
    this.input.setAttribute('aria-expanded', 'true');
  }

  close() {
    this.listbox.setAttribute('hidden', '');
    this.input.setAttribute('aria-expanded', 'false');
    this.activeIndex = -1;
    this.input.removeAttribute('aria-activedescendant');
  }

  onInput(e) {
    const query = e.target.value.toLowerCase();
    const filtered = this.options.filter(opt => opt.toLowerCase().includes(query));
    
    if (filtered.length > 0) {
      this.renderOptions(filtered);
      this.open();
    } else {
      this.close();
    }
  }

  renderOptions(items) {
    this.listbox.innerHTML = '';
    items.forEach((item, index) => {
      const li = document.createElement('li');
      li.id = `fruit-option-${index}`;
      li.role = 'option';
      li.textContent = item;
      this.listbox.appendChild(li);
    });
  }

  onKeyDown(e) {
    const isOpen = this.input.getAttribute('aria-expanded') === 'true';
    const items = this.listbox.querySelectorAll('li');

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          this.open();
        } else {
          this.activeIndex = Math.min(this.activeIndex + 1, items.length - 1);
          this.updateActiveDescendant(items);
        }
        break;
      case 'ArrowUp':
        e.preventDefault();
        if (isOpen) {
          this.activeIndex = Math.max(this.activeIndex - 1, 0);
          this.updateActiveDescendant(items);
        }
        break;
      case 'Enter':
        if (isOpen && this.activeIndex >= 0) {
          e.preventDefault();
          this.input.value = items[this.activeIndex].textContent;
          this.close();
        }
        break;
      case 'Escape':
        this.close();
        break;
    }
  }

  updateActiveDescendant(items) {
    items.forEach((item, idx) => {
      if (idx === this.activeIndex) {
        item.setAttribute('aria-selected', 'true');
        item.classList.add('is-active');
        this.input.setAttribute('aria-activedescendant', item.id);
      } else {
        item.removeAttribute('aria-selected');
        item.classList.remove('is-active');
      }
    });
  }

  onOptionClick(e) {
    if (e.target.role === 'option') {
      this.input.value = e.target.textContent;
      this.close();
    }
  }
}

// Initialization
const comboContainer = document.querySelector('.combobox-wrapper');
new AccessibleCombobox(comboContainer, ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry']);

Testing Workflows: How to Verify Your Work

Writing accessible code requires rigorous validation. Never rely solely on automated linters.

1. Automated Testing

Integrate tools like axe-core into your CI/CD pipeline or test suites (Jest / Vitest with @testing-library/dom):

import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

test('modal should have no accessibility violations', async () => {
  const container = document.createElement('div');
  container.innerHTML = /* html */`...`;
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

2. Manual Keyboard Audit

Put your mouse away. Can you:

  • Tab to the trigger element?
  • Activate it using Enter or Space?
  • Navigate all internal items using Tab (for modals) or Arrow Keys (for comboboxes)?
  • Close the component using Escape?
  • Confirm focus returns to the correct originating element?

3. Screen Reader Verification

Test your components with native screen readers:

  • macOS: VoiceOver (Cmd + F5)
  • Windows: NVDA (Free and open-source)
  • Mobile: TalkBack (Android) or VoiceOver (iOS)

Listen closely to ensure dynamic changes (like listbox opening or active options shifting) are announced correctly.


Conclusion

Designing complex UI components from scratch demands a deep understanding of browser event models, semantic HTML, and accessibility specifications. By treating focus management as a primary feature rather than a patch, and by implementing strict ARIA contracts, you create robust, inclusive applications that empower every user.

More posts