Keyboard Project — Build an Accessible Custom Select Menu
In this tutorial, you will learn about Keyboard Project. We cover key concepts, practical examples, and best practices to help you master this topic.
Build a custom accessible select menu using listbox pattern with roving tabindex, arrow key navigation, aria-selected, and full keyboard support.
In this project, you'll implement a complete Keyboard Navigation widget.
What You'll Learn
By the end of this project, you'll have built a fully keyboard-accessible custom select menu that meets all ARIA Authoring Practices Guide requirements.
Why It Matters
Custom select menus are one of the most common keyboard Accessibility failures. Mastering this pattern prepares you for all other custom form controls.
Real-World Use
Doda Browser uses custom select menus throughout its settings interface, following this exact pattern.
Project Architecture
flowchart TD A[Custom Select] --> B[Trigger button] A --> C[Dropdown listbox] B --> D[aria-haspopup='listbox'] B --> E[aria-expanded toggle] C --> F[role='listbox'] C --> G[role='option' items] C --> H[Roving tabindex] C --> I[Arrow key navigation]
HTML Structure
<div class="custom-select">
<button
id="select-trigger"
aria-haspopup="listbox"
aria-expanded="false"
aria-label="Select scan type"
class="select-trigger"
>
Quick Scan
<span aria-hidden="true">▼</span>
</button>
<ul
id="select-listbox"
role="listbox"
aria-label="Scan types"
class="select-dropdown"
hidden
>
<li role="option" id="opt-quick" aria-selected="true" tabindex="0">
Quick Scan
</li>
<li role="option" id="opt-full" aria-selected="false" tabindex="-1">
Full Scan
</li>
<li role="option" id="opt-custom" aria-selected="false" tabindex="-1">
Custom Scan
</li>
</ul>
</div>
JavaScript Implementation
class CustomSelect {
constructor(container) {
this.container = container;
this.trigger = container.querySelector('.select-trigger');
this.listbox = container.querySelector('[role="listbox"]');
this.options = Array.from(this.listbox.querySelectorAll('[role="option"]'));
this.trigger.addEventListener('click', () => this.toggle());
this.trigger.addEventListener('keydown', (e) => this.handleTriggerKey(e));
this.listbox.addEventListener('keydown', (e) => this.handleListboxKey(e));
this.options.forEach(opt => opt.addEventListener('click', () => this.select(opt)));
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) this.close();
});
}
toggle() {
const expanded = this.trigger.getAttribute('aria-expanded') === 'true';
expanded ? this.close() : this.open();
}
open() {
this.trigger.setAttribute('aria-expanded', 'true');
this.listbox.hidden = false;
const selected = this.options.find(o => o.getAttribute('aria-selected') === 'true');
selected.focus();
}
close() {
this.trigger.setAttribute('aria-expanded', 'false');
this.listbox.hidden = true;
this.trigger.focus();
}
select(option) {
this.options.forEach(o => {
o.setAttribute('aria-selected', 'false');
o.setAttribute('tabindex', '-1');
});
option.setAttribute('aria-selected', 'true');
option.setAttribute('tabindex', '0');
this.trigger.textContent = option.textContent;
this.close();
}
handleTriggerKey(e) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
this.open();
}
}
handleListboxKey(e) {
const currentIndex = this.options.indexOf(document.activeElement);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.focusOption((currentIndex + 1) % this.options.length);
break;
case 'ArrowUp':
e.preventDefault();
this.focusOption((currentIndex - 1 + this.options.length) % this.options.length);
break;
case 'Enter':
case ' ':
e.preventDefault();
this.select(this.options[currentIndex]);
break;
case 'Escape':
this.close();
break;
case 'Home':
e.preventDefault();
this.focusOption(0);
break;
case 'End':
e.preventDefault();
this.focusOption(this.options.length - 1);
break;
}
}
focusOption(index) {
this.options[index].focus();
}
}
new CustomSelect(document.querySelector('.custom-select'));
Testing Checklist
- Trigger is focusable via Tab
- Enter/Space/ArrowDown opens the listbox
- Arrow keys navigate options
- Enter/Space selects and closes
- Escape closes without selecting
- Home/End navigate to first/last option
- Click outside closes the listbox
- Selected option has tabindex="0"
Common Mistakes
- Not closing the listbox on Escape: Users expect Escape to cancel selection.
- Not closing on outside click: The listbox stays open when clicking elsewhere.
- Focus trap when listbox is closed: Focus must return to the trigger.
- Not updating aria-selected with selection: The attribute must reflect the actual selection.
- Not handling Home and End keys: Users expect these boundary keys.
FAQ
Mini Project
You just built the mini project. Add typeahead support so that typing a letter focuses the first option starting with that letter.
What's Next
This completes the Keyboard Navigation series. Continue to Focus Management Deep Dive for advanced focus techniques.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro