Accessible dropdown requires keyboard navigation (Arrow keys, Enter, Escape) and ARIA attributes to work with screen readers.
Think of an accessible dropdown like a physical vending machine — a sighted user can see all the options at a glance and press a button, but a screen reader user needs to be told what is available and be able to move through the options one by one using a predictable pattern.
Why it matters: Without proper accessibility, users relying on keyboard navigation or screen readers cannot interact with your dropdown menus at all. This affects approximately 15–20% of web users who have some form of disability. Legal compliance (ADA, WCAG) also requires keyboard-operable and screen-reader-friendly components.
1import { useState, useRef, useEffect, useCallback } from "react";23interface MenuItem {4 id: string;5 label: string;6 onClick: () => void;7}89function Dropdown({ trigger, items }: { trigger: React.ReactNode; items: MenuItem[] }) {10 const [isOpen, setIsOpen] = useState(false);11 const [activeIndex, setActiveIndex] = useState(-1);12 const menuRef = useRef<HTMLUListElement>(null);13 const triggerRef = useRef<HTMLButtonElement>(null);14 const itemRefs = useRef<(HTMLLIElement | null)[]>([]);1516 // Focus the active item whenever activeIndex changes17 useEffect(() => {18 if (isOpen && activeIndex >= 0) {19 itemRefs.current[activeIndex]?.focus();20 }21 }, [isOpen, activeIndex]);2223 // Close on outside click24 useEffect(() => {25 if (!isOpen) return;26 const handleClickOutside = (e: MouseEvent) => {27 if (!menuRef.current?.contains(e.target as Node) &&28 !triggerRef.current?.contains(e.target as Node)) {29 setIsOpen(false);30 }31 };32 document.addEventListener("mousedown", handleClickOutside);33 return () => document.removeEventListener("mousedown", handleClickOutside);34 }, [isOpen]);3536 const onSelect = useCallback((item: MenuItem) => {37 item.onClick();38 }, []);3940 const handleKeyDown = (e: React.KeyboardEvent) => {41 if (!isOpen) {42 if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {43 e.preventDefault();44 setIsOpen(true);45 setActiveIndex(0);46 }47 return;48 }4950 switch (e.key) {51 case "ArrowDown":52 e.preventDefault();53 setActiveIndex(i => (i + 1) % items.length);54 break;55 case "ArrowUp":56 e.preventDefault();57 setActiveIndex(i => (i - 1 + items.length) % items.length);58 break;59 case "Home":60 e.preventDefault();61 setActiveIndex(0);62 break;63 case "End":64 e.preventDefault();65 setActiveIndex(items.length - 1);66 break;67 case "Escape":68 setIsOpen(false);69 triggerRef.current?.focus();70 break;71 case "Enter":72 case " ":73 e.preventDefault();74 if (activeIndex >= 0) {75 onSelect(items[activeIndex]);76 setIsOpen(false);77 triggerRef.current?.focus();78 }79 break;80 }81 };8283 return (84 <div className="dropdown">85 <button86 ref={triggerRef}87 aria-haspopup="listbox"88 aria-expanded={isOpen}89 aria-controls="menu-listbox"90 onClick={() => setIsOpen(!isOpen)}91 onKeyDown={handleKeyDown}92 >93 {trigger}94 </button>95 {isOpen && (96 <ul97 ref={menuRef}98 id="menu-listbox"99 role="listbox"100 aria-label="Menu"101 onKeyDown={handleKeyDown}102 >103 {items.map((item, i) => (104 <li105 key={item.id}106 ref={el => itemRefs.current[i] = el}107 role="option"108 aria-selected={i === activeIndex}109 tabIndex={-1}110 onClick={() => { onSelect(item); setIsOpen(false); }}111 >112 {item.label}113 </li>114 ))}115 </ul>116 )}117 </div>118 );119}
Key ARIA attributes:
aria-haspopup="listbox" — tells screen readers that this button controls a listbox.aria-expanded={isOpen} — indicates whether the menu is currently open.aria-controls="menu-listbox" — links the trigger to the menu element by ID.role="listbox" + role="option" — provides semantic meaning for the menu structure.aria-selected={i === activeIndex} — announces which option is currently highlighted.How it works step-by-step:
Common mistakes to avoid:
onClick without onKeyDown — mouse-only dropdowns fail keyboard users entirely.aria-expanded — screen readers cannot announce the open/closed state.Production considerations:
aria-controls that matches the list element's ID.useCallback for event handlers to prevent unnecessary re-renders.role="menu" + role="menuitem" instead of listbox for action menus (vs selection menus).