All posts
6 Oct 2026

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:

  1. The Combobox Button / Input: The trigger element that receives initial focus and opens the menu. It has role="combobox".
  2. The Listbox: The popup container holding the selectable options, with role="listbox".
  3. 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.

bash
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!

More posts