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
- 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. - Second Rule of ARIA: Do not change native semantics unless necessary (e.g., don’t turn a
buttoninto a presentation role without a valid reason). - Third Rule of ARIA: All interactive ARIA controls must be usable via the keyboard.
- 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:
- Focus Trap: Focus must remain trapped inside the modal until it is closed.
- Inert Background: Everything outside the modal must be hidden from screen readers and rendered inert.
- Escape Hatch: Pressing the
Escapekey must close the modal, and focus must return to the element that triggered it.
HTML Structure
<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-expandedtoggles betweentrueandfalse.aria-controlspoints to the ID of the listbox popup.- The popup listbox must have
role="listbox", and its items must haverole="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
EnterorSpace? - Navigate all internal items using
Tab(for modals) orArrow 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.