Accessible Dropdown
by Pratik Rai
Read the full write-upBuild a dropdown from scratch — no <select>, no library.
This was asked as a machine-coding round followed by a long discussion of edge cases, keyboard support and accessibility, so the interesting half is everything past "it opens and closes".
Requirements
Clicking the trigger opens the list; clicking it again, or clicking outside, closes it.
Arrow Up and Arrow Down move a highlight through the options without selecting anything.
Enter or Space selects the highlighted option and closes the list. Escape closes without selecting.
Closing returns focus to the trigger.
Opening puts the highlight on the currently selected option, not always the first.
The trigger carries
aria-haspopup,aria-expandedandaria-activedescendant; the list is arole="listbox"ofrole="option"items witharia-selected.
Notes
The highlight and the selection are two different things —
.option-activeand.option-selectedstyle them separately.Home and End jumping to the first and last option is a nice extra, and is what the ARIA listbox pattern specifies.
Hints
Accessible Dropdown (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState, useRef, useEffect } from 'react';
2
3const OPTIONS = [
4 { value: 'react', label: 'React' },
5 { value: 'vue', label: 'Vue' },
6 { value: 'svelte', label: 'Svelte' },
7 { value: 'solid', label: 'Solid' },
8 { value: 'angular', label: 'Angular' },
9];
10
11export default function App() {
12 const [value, setValue] = useState(null);
13 const [open, setOpen] = useState(false);
14 // The keyboard highlight is separate from the selection: moving through the
15 // list must not commit a value until Enter.
16 const [activeIndex, setActiveIndex] = useState(0);
17
18 const fieldRef = useRef(null);
19 const triggerRef = useRef(null);
20
21 const selected = OPTIONS.find((o) => o.value === value);
22
23 const openList = () => {
24 // Open with the highlight on the current selection, so arrowing starts
25 // from where the user already is rather than from the top.
26 const current = OPTIONS.findIndex((o) => o.value === value);
27 setActiveIndex(current === -1 ? 0 : current);
28 setOpen(true);
29 };
30
31 const close = () => {
32 setOpen(false);
33 // Focus has to come back to the trigger, or a keyboard user is dropped at
34 // the top of the document with no way back.
35 triggerRef.current?.focus();
36 };
37
38 const commit = (index) => {
39 setValue(OPTIONS[index].value);
40 close();
41 };
42
43 // Pointer-down rather than click: a click fires after mouseup, so a press
44 // that starts inside and ends outside would not close the list.
45 useEffect(() => {
46 if (!open) return undefined;
47 const onPointerDown = (event) => {
48 if (!fieldRef.current?.contains(event.target)) setOpen(false);
49 };
50 document.addEventListener('pointerdown', onPointerDown);
51 return () => document.removeEventListener('pointerdown', onPointerDown);
52 }, [open]);
53
54 const onKeyDown = (event) => {
55 if (!open) {
56 if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
57 event.preventDefault();
58 openList();
59 }
60 return;
61 }
62
63 if (event.key === 'ArrowDown') {
64 event.preventDefault();
65 setActiveIndex((i) => (i + 1) % OPTIONS.length);
66 } else if (event.key === 'ArrowUp') {
67 event.preventDefault();
68 setActiveIndex((i) => (i - 1 + OPTIONS.length) % OPTIONS.length);
69 } else if (event.key === 'Home') {
70 event.preventDefault();
71 setActiveIndex(0);
72 } else if (event.key === 'End') {
73 event.preventDefault();
74 setActiveIndex(OPTIONS.length - 1);
75 } else if (event.key === 'Enter' || event.key === ' ') {
76 event.preventDefault();
77 commit(activeIndex);
78 } else if (event.key === 'Escape') {
79 event.preventDefault();
80 close();
81 } else if (event.key === 'Tab') {
82 // Tabbing away is a dismissal, not a selection.
83 setOpen(false);
84 }
85 };
86
87 return (
88 <div className="demo">
89 <h1>Framework</h1>
90
91 <div className="field" ref={fieldRef}>
92 <button
93 className="trigger"
94 ref={triggerRef}
95 onClick={() => (open ? close() : openList())}
96 onKeyDown={onKeyDown}
97 aria-haspopup="listbox"
98 aria-expanded={open}
99 aria-activedescendant={open ? 'option-' + OPTIONS[activeIndex].value : undefined}
100 >
101 {selected ? selected.label : <span className="placeholder">Select one…</span>}
102 <span className="caret">▾</span>
103 </button>
104
105 {open && (
106 <ul className="list" role="listbox" aria-label="Framework">
107 {OPTIONS.map((option, index) => (
108 <li
109 key={option.value}
110 id={'option-' + option.value}
111 role="option"
112 aria-selected={option.value === value}
113 className={
114 'option' +
115 (index === activeIndex ? ' option-active' : '') +
116 (option.value === value ? ' option-selected' : '')
117 }
118 onPointerEnter={() => setActiveIndex(index)}
119 onClick={() => commit(index)}
120 >
121 {option.label}
122 </li>
123 ))}
124 </ul>
125 )}
126 </div>
127
128 <p className="readout">Value: {value ?? 'none'}</p>
129 </div>
130 );
131}How it works
Almost everyone builds the open/close half in five minutes and then spends the rest of the round on the parts that make it a real control.
The key modelling decision is separating the highlight from the selection. They feel like one thing and are not: arrowing down moves the highlight and must not change the value, because a keyboard user browsing the list has not committed to anything yet. Two pieces of state, and aria-activedescendant exists precisely to describe that split — focus stays on the trigger while the announced item moves.
pointerdown instead of click for outside dismissal is the detail interviewers notice. A click event fires on mouseup, so a drag that begins inside the list and releases outside never produces a click on the document, and the list stays open.
Returning focus to the trigger on close is an accessibility requirement rather than a nicety. Remove it and a keyboard user who presses Escape has focus on an element that no longer exists, which drops them at the top of the document.
The honest follow-up: for a plain single-select, a native <select> gets all of this for free and works better on mobile. Being able to say when you would build this — custom option rendering, multi-select, async loading — and when you would not is part of the answer.