Arrow Keys, Type-Ahead, and ARIA: Building an Accessible Custom Select in React
Learn how to build a production-ready, fully accessible custom select and combobox component in React and TypeScript featuring roving tabindex, arrow navigation, type-ahead search, and robust screen reader support.
Arrow Keys, Type-Ahead, and ARIA: Building an Accessible Custom Select in React
When building design systems in React, native <select> elements are often abandoned in favor of custom UI components. While native elements handle accessibility, keyboard navigation, and mobile viewports out of the box, they are notoriously difficult to style consistently across browsers.
However, building a custom select from scratch comes with a major trap: breaking accessibility. A standard <div> styled to look like a dropdown is invisible to screen readers and inaccessible to keyboard-only users.
In this walkthrough, we will build a production-ready, fully accessible custom select component using React and TypeScript. We will cover ARIA authoring practices, roving tabindex, arrow key navigation, type-ahead search, and focus management.
The Anatomy of an Accessible Select
According to the WAI-ARIA Authoring Practices Guide (APG), a custom select or combobox requires a strict pattern of roles, states, and properties. We need:
- A Trigger Button: Acts as the launcher. It requires
aria-haspopup="listbox",aria-expanded, andaria-controlspointing to the dropdown list ID. - A Listbox Container: Holds the options and must have
role="listbox". - Option Elements: Each item must have
role="option"and a dynamicaria-selectedstate. - Focus Management: Focus must move intuitively when using
ArrowUp,ArrowDown,Home,End, and typing characters for type-ahead search.
Setting Up the TypeScript Interfaces
Let’s start by defining our types. We need a flexible option structure and props interface.
import React, { useState, useRef, useEffect, KeyboardEvent, useId } from 'react';
export interface SelectOption {
label: string;
value: string | number;
disabled?: boolean;
}
interface AccessibleSelectProps {
options: SelectOption[];
value: SelectOption['value'];
onChange: (value: SelectOption['value']) => void;
placeholder?: string;
label: string;
disabled?: boolean;
}
Building the Component Skeleton
We will manage three primary pieces of state: whether the dropdown is open (isOpen), the index of the currently highlighted option (highlightedIndex), and refs to manage DOM focus.
export const AccessibleSelect: React.FC<AccessibleSelectProps> = ({
options,
value,
onChange,
placeholder = 'Select an option...',
label,
disabled = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const buttonRef = useRef<HTMLButtonElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
const idPrefix = useId();
const listboxId = `${idPrefix}-listbox`;
const labelId = `${idPrefix}-label`;
const selectedOption = options.find((opt) => opt.value === value);
// Component implementation continues below...
Handling Keyboard Navigation
Keyboard interactions are what separate a mediocre dropdown from an accessible one. Users expect:
EnterorSpace: Opens the listbox or selects the highlighted option.ArrowDown/ArrowUp: Opens the listbox and moves focus/highlight down or up through non-disabled options.Escape: Closes the listbox without selecting, returning focus to the trigger.Home/End: Jumps to the first or last available option.- Type-Ahead: Typing letters jumps directly to matching options.
Let’s implement the handleKeyDown function for the trigger and listbox:
const handleTriggerKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;
switch (e.key) {
case 'Enter':
case ' ':'
case 'ArrowDown':
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
// Highlight currently selected item, or first item
const selectedIdx = options.findIndex((opt) => opt.value === value);
setHighlightedIndex(selectedIdx >= 0 ? selectedIdx : 0);
}
break;
case 'Escape':
if (isOpen) {
e.preventDefault();
setIsOpen(false);
}
break;
}
};
Listbox and Type-Ahead Logic
When the listbox is open, keystrokes must target the options list. We also want to implement type-ahead search so users can type the first few letters of an option to jump to it.
const typeAheadBuffer = useRef<string>('');
const typeAheadTimeout = useRef<NodeJS.Timeout | null>(null);
const handleTypeAhead = (char: string) => {
if (typeAheadTimeout.current) clearTimeout(typeAheadTimeout.current);
typeAheadBuffer.current += char.toLowerCase();
const matchingIndex = options.findIndex((opt) =>
opt.label.toLowerCase().startsWith(typeAheadBuffer.current)
);
if (matchingIndex !== -1) {
setHighlightedIndex(matchingIndex);
}
typeAheadTimeout.current = setTimeout(() => {
typeAheadBuffer.current = '';
}, 500);
};
const handleListKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!isOpen) return;
switch (e.key) {
case 'ArrowDown': {
e.preventDefault();
setHighlightedIndex((prev) => {
let next = prev + 1;
while (next < options.length && options[next].disabled) {
next++;
}
return next < options.length ? next : prev;
});
break;
}
case 'ArrowUp': {
e.preventDefault();
setHighlightedIndex((prev) => {
let next = prev - 1;
while (next >= 0 && options[next].disabled) {
next--;
}
return next >= 0 ? next : prev;
});
break;
}
case 'Home':
e.preventDefault();
setHighlightedIndex(options.findIndex((o) => !o.disabled));
break;
case 'End':
e.preventDefault();
for (let i = options.length - 1; i >= 0; i--) {
if (!options[i].disabled) {
setHighlightedIndex(i);
break;
}
}
break;
case 'Enter':
case ' ':
e.preventDefault();
if (highlightedIndex >= 0 && !options[highlightedIndex].disabled) {
onChange(options[highlightedIndex].value);
setIsOpen(false);
buttonRef.current?.focus();
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
buttonRef.current?.focus();
break;
default:
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
handleTypeAhead(e.key);
}
break;
}
};
Managing Focus and Roving Tabindex
Screen readers rely heavily on aria-activedescendant or DOM focus movement. Using aria-activedescendant on the listbox pointing to the ID of the currently highlighted option is the cleanest pattern because it keeps focus on the container while communicating the active child to assistive tech.
Let’s ensure our DOM reflects this state correctly.
// Scroll highlighted option into view when index changes
useEffect(() => {
if (isOpen && highlightedIndex >= 0) {
const optionElement = document.getElementById(`${idPrefix}-option-${highlightedIndex}`);
optionElement?.scrollIntoView({ block: 'nearest' });
}
}, [highlightedIndex, isOpen, idPrefix]);
// Return focus to trigger when closing
useEffect(() => {
if (!isOpen) {
setHighlightedIndex(-1);
}
}, [isOpen]);
Putting It Together: The JSX Structure
Now we render the components with all mandatory ARIA attributes in place.
return (
<div className="relative w-72">
<label id={labelId} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
<div className="relative">
{/* Trigger Button */}
<button
ref={buttonRef}
type="button"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-labelledby={labelId}
disabled={disabled}
onClick={() => setIsOpen((prev) => !prev)}
onKeyDown={handleTriggerKeyDown}
className="w-full flex items-center justify-between px-4 py-2 bg-white border border-gray-300 rounded-md shadow-sm text-left focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
>
<span className={selectedOption ? 'text-gray-900' : 'text-gray-400'}>
{selectedOption ? selectedOption.label : placeholder}
</span>
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown Listbox */}
{isOpen && (
<ul
ref={listboxRef}
id={listboxId}
role="listbox"
aria-labelledby={labelId}
aria-activedescendant={
highlightedIndex >= 0 ? `${idPrefix}-option-${highlightedIndex}` : undefined
}
tabIndex={-1}
onKeyDown={handleListKeyDown}
className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
>
{options.map((option, index) => {
const isSelected = option.value === value;
const isHighlighted = index === highlightedIndex;
return (
<li
key={option.value}
id={`${idPrefix}-option-${index}`}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
onClick={() => {
if (!option.disabled) {
onChange(option.value);
setIsOpen(false);
buttonRef.current?.focus();
}
}}
onMouseEnter={() => !option.disabled && setHighlightedIndex(index)}
className={`cursor-default select-none relative py-2 pl-3 pr-9 ${
isHighlighted ? 'bg-blue-600 text-white' : 'text-gray-900'
} ${option.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className={`block truncate ${isSelected ? 'font-semibold' : 'font-normal'}`}>
{option.label}
</span>
{isSelected && (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
isHighlighted ? 'text-white' : 'text-blue-600'
}`}
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1.e 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="veenodd"
/>
</svg>
</span>
)}
</li>
);
})}
</ul>
)}
</div>
</div>
);
};
Testing Your Implementation
Before shipping your custom select component, perform these manual accessibility sanity checks:
- The Screen Reader Test: Turn on VoiceOver (macOS) or NVDA (Windows). Focus the select trigger. Does it announce the label, state (
collapsed), and role (combobox)? Press Space/Enter. Does it announce the expanded listbox and the currently focused option viaaria-activedescendant?- The Keyboard-Only Test: Unplug your mouse. Can you open the dropdown using
ArrowDown, navigate options, select an item withEnter, and close withEscapewithout losing focus?- Type-Ahead Check: Open the dropdown and type letters rapidly. Does the focus jump straight to matching items?
Conclusion
Building custom components in React offers boundless design freedom, but it shifts the responsibility of accessibility entirely onto your shoulders. By adhering strictly to the WAI-ARIA combobox patterns, implementing robust keyboard handlers, and managing focus cleanly using aria-activedescendant, you can create custom selects that delight sighted users while remaining fully navigable for assistive technology users.