Beyond the Native Select: Crafting an Accessible Custom Dropdown in React
A step-by-step tutorial on building a fully accessible custom dropdown component in React and TypeScript using floating-ui, complete with keyboard navigation, type-ahead search, and robust ARIA combobox patterns.
Beyond the Native Select: Crafting an Accessible Custom Dropdown in React
Every frontend developer eventually faces the same dilemma: the native HTML <select> element is wonderfully accessible, robust, and mobile-friendly, but its styling limitations are notoriously rigid. The moment your product designer hands you a mockup with custom typography, icons inside options, or multi-column layouts, the native select has to go.
However, building a custom dropdown from scratch is a notorious trap. If you just throw together a <div> with some click handlers, you instantly break screen readers, keyboard navigation, focus management, and mobile support.
In this tutorial, we will build a production-ready, highly accessible custom dropdown component in React and TypeScript using floating-ui for smart positioning. We will implement the WAI-ARIA Combobox pattern, complete with full arrow-key navigation, type-ahead search, and robust state management.
The Anatomy of an Accessible Dropdown
Before writing code, we need to understand what makes a custom select accessible. According to the WAI-ARIA Authoring Practices Guide (APG), a custom select can be implemented using either the Listbox pattern or the Combobox pattern.
Because we want to support filtering/type-ahead search alongside standard selection, we will implement the Combobox pattern. This consists of:
- The Combobox Button / Input: The trigger element that receives initial focus and opens the menu. It has
role="combobox". - The Listbox: The popup container holding the selectable options, with
role="listbox". - Options: The individual selectable items, each with
role="option".
We also need to manage critical ARIA states:
aria-expanded: Tells screen readers whether the dropdown is open or closed.aria-controls: Links the combobox trigger to the listbox ID.aria-activedescendant: Points to the ID of the currently highlighted option, allowing screen readers to announce focused items without shifting real DOM focus away from the input.
Setting Up the Project
First, install the necessary dependencies. We’ll need floating-ui for handling dropdown positioning and collision detection.
npm install @floating-ui/react
Let’s define our TypeScript types for the options and component props.
// types.ts
export interface Option {
value: string;
label: string;
disabled?: boolean;
}
export interface CustomSelectProps {
options: Option[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
label: string;
}
Building the Custom Select Component
Let’s assemble the core component logic. We’ll use @floating-ui/react to handle positioning, click-away behavior, and list navigation helpers.
// CustomSelect.tsx
import React, { useState, useRef, useEffect, useId } from (
'react'
);
import {
useFloating,
useClick,
useDismiss,
useRole,
useListNavigation,
useInteractions,
offset,
flip,
size,
autoUpdate,
} from '@floating-ui/react';
import { CustomSelectProps, Option } from './types';
import styles from './CustomSelect.module.css';
export const CustomSelect: React.FC<CustomSelectProps> = ({
options,
value,
onChange,
placeholder = 'Select an option...',
label,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [searchString, setSearchString] = useState('');
const labelId = useId();
const listId = useId();
const selectedOption = options.find((opt) => opt.value === value);
// Floating UI setup
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [
offset(4),
flip(),
size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`,
});
},
}),
],
});
// List navigation and interaction hooks from Floating UI
const listRef = useRef<(HTMLElement | null)[]>([]);
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'combobox' });
const navigation = useListNavigation(context, {
listRef,
activeIndex,
onNavigate: setActiveIndex,
loop: true,
allowSelect: false,
});
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
click,
dismiss,
role,
navigation,
]);
// Handle type-ahead search
useEffect(() => {
if (!isOpen || !searchString) return;
const timer = setTimeout(() => setSearchString(''), 500);
const matchingIndex = options.findIndex((opt) =>
opt.label.toLowerCase().startsWith(searchString.toLowerCase())
);
if (matchingIndex !== -1) {
setActiveIndex(matchingIndex);
}
return () => clearTimeout(timer);
}, [searchString, isOpen, options]);
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (isOpen && activeIndex !== null) {
const selected = options[activeIndex];
if (selected && !selected.disabled) {
onChange(selected.value);
setIsOpen(false);
}
} else {
setIsOpen(true);
}
} else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
// Capture printable characters for type-ahead
setSearchString((prev) => prev + event.key);
}
};
return (
<div className={styles.container}>
<label id={labelId} className={styles.label}>
{label}
</label>
{/* Combobox Trigger Button */}
<button
ref={refs.setReference}
type="button"
aria-labelledby={labelId}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={isOpen ? listId : undefined}
aria-activedescendant={
activeIndex !== null ? `${listId}-item-${activeIndex}` : undefined
}
{...getReferenceProps({
onKeyDown: handleKeyDown,
})}
className={styles.trigger}
>
<span>
{selectedOption ? selectedOption.label : placeholder}
</span>
<span className={styles.chevron} aria-hidden="true">
▼
</span>
</button>
{/* Dropdown Menu / Listbox */}
{isOpen && (
<div
ref={refs.setFloating}
id={listId}
role="listbox"
aria-labelledby={labelId}
{...getFloatingProps()}
className={styles.listbox}
>
{options.map((option, index) => {
const isSelected = option.value === value;
const isActive = index === activeIndex;
return (
<div
key={option.value}
id={`${listId}-item-${index}`}
ref={(node) => {
listRef.current[index] = node;
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
{...getItemProps({
onClick: () => {
if (!option.disabled) {
onChange(option.value);
setIsOpen(false);
}
},
})}
className={`
${styles.option}
${isSelected ? styles.selected : ''}
${isActive ? styles.active : ''}
${option.disabled ? styles.disabled : ''}
`}
>
{option.label}
</div>
);
})}
</div>
)}
</div>
);
};
Styling the Component
To ensure our custom select looks clean and clearly indicates focus and active states, apply the following CSS modules file:
/* CustomSelect.module.css */
.container {
display: flex;
flex-direction: column;
gap: 6px;
font-family: system-ui, -apple-system, sans-serif;
width: 280px;
}
.label {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
}
.trigger {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background-color: #ffffff;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
color: #1f2937;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.trigger:focus-visible {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}
.chevron {
font-size: 0.75rem;
color: #6b7280;
}
.listbox {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
max-height: 240px;
overflow-y: auto;
padding: 4px;
z-index: 50;
}
.option {
padding: 8px 12px;
border-radius: 6px;
font-size: 0.95rem;
color: #374151;
cursor: pointer;
}
.option.active {
background-color: #f3f4f6;
color: #111827;
}
.option.selected {
font-weight: 600;
color: #2563eb;
}
.option.disabled {
opacity: 0.5;
cursor: not-allowed;
}
Deep Dive into Accessibility Features
Let’s review why this implementation satisfies rigorous accessibility audits:
1. aria-activedescendant vs DOM Focus
When navigating a native dropdown or standard list with arrow keys, browser focus usually jumps between DOM elements. However, in our combobox pattern, real DOM focus remains on the trigger button.
By updating aria-activedescendant to reference the ID of the currently hovered or keyboard-highlighted option (id="list-item-2"), screen readers know precisely what option is active and announce it immediately, without breaking user focus on the trigger input.
2. Robust Keyboard Support
Our component supports the full keyboard spec expected by screen reader and power users:
Enter/Space: Toggles the menu open/closed, or confirms the active option.ArrowDown/ArrowUp: Cycles through items sequentially, looping back to the top/bottom when enabled.Escape: Closes the dropdown instantly.- Type-ahead Search: Typing characters while the dropdown is open rapidly jumps focus to the first option matching the typed prefix string.
3. Proper Labeling and Relationships
Screen readers rely heavily on contextual relationships. We explicitly tied our visible <label> to our combobox via aria-labelledby, and linked the popup container using aria-controls and aria-haspopup="listbox".
Conclusion
Moving away from the native <select> element doesn’t mean abandoning accessibility. By combining React, TypeScript, Floating UI, and strict adherence to the WAI-ARIA Combobox specification, you can deliver pixel-perfect custom designs that remain fully operable for keyboard-only users and screen reader navigators alike.
Always test your custom components with real assistive technologies like VoiceOver (macOS) or NVDA (Windows) to verify that your aria-* attributes translate into a seamless user experience!