Build a Multi-Select Dropdown
Learn how to build an accessible multi-select dropdown from scratch with search filtering, chip display, keyboard navigation, and outside click detection. This question tests your command of React refs, event handling, and ARIA patterns.
Table of Contents
Problem Statement
Build a multi-select dropdown component that allows users to select multiple options from a list. This is a staple UI pattern found in forms, filters, and tag pickers across every major web app.
- Click a trigger button to open/close the dropdown
- Select and deselect options by clicking them
- Display selected items as removable chips in the trigger
- Filter options with a search input inside the dropdown
- Close the dropdown when clicking outside
- Navigate options with Arrow keys, toggle with Enter, close with Escape
- Support Select All and Clear All actions
Why this question?
Multi-select dropdowns test a wide range of skills in a single component: controlled state, refs, DOM event listeners, keyboard handling, and accessibility. Interviewers use it to see if you can build production-quality UI, not just functional UI.
Component Anatomy
Before writing code, break the component into its visual parts. This is how interviewers expect you to think:
Trigger Button
Shows selected chips or placeholder text. Clicking it toggles the dropdown. Has a chevron icon that rotates when open.
Dropdown Panel
Absolutely positioned below the trigger. Contains the search input, actions bar, and scrollable options list.
Search Input
Filters the options list in real time. Auto-focused when the dropdown opens. Keyboard events bubble up for navigation.
Option Items
Each option has a checkbox indicator and label. Clicking toggles selection. Highlighted state for keyboard focus.
┌─────────────────────────────────────────┐ │ [React ×] [Vue ×] [Svelte ×] ▼ │ ← Trigger (chips + chevron) └─────────────────────────────────────────┘ ┌─────────────────────────────────────────┐ │ 🔍 Search... │ ← Search input ├─────────────────────────────────────────┤ │ Select All Clear All │ ← Actions bar ├─────────────────────────────────────────┤ │ ☑ React │ │ ☑ Vue │ ← Options list │ ☐ Angular │ (scrollable) │ ☑ Svelte │ │ ☐ Next.js │ │ ... │ └─────────────────────────────────────────┘
State Design
The component needs five pieces of state. Keeping state minimal and deriving everything else is key.
| State | Type | Purpose |
|---|---|---|
| selected | string[] | Array of selected option values |
| isOpen | boolean | Whether the dropdown panel is visible |
| search | string | Current search/filter text |
| focusedIndex | number | Index of keyboard-focused option (-1 = none) |
const [selected, setSelected] = useState<string[]>([]); const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); const [focusedIndex, setFocusedIndex] = useState(-1); // Refs for DOM access const wrapperRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLUListElement>(null); const searchInputRef = useRef<HTMLInputElement>(null); // Derived state — no extra useState needed const filteredOptions = OPTIONS.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()) );
Interview tip
Interviewers watch for unnecessary state. The filtered options list is derived fromsearch +OPTIONS — storing it in state would be redundant and a red flag.
Outside Click Detection
Closing the dropdown when the user clicks outside is essential UX. The pattern uses a ref on the wrapper element and a global mousedown listener.
Attach a ref to the wrapper div
The wrapper contains both the trigger button and the dropdown panel. Any click inside either of these is 'inside'.
Add a mousedown listener on document
Use useEffect to add the listener when the component mounts. Return a cleanup function to remove it on unmount.
Check if click target is inside the wrapper
In the handler, use wrapperRef.current.contains(event.target). If it returns false, the click was outside — close the dropdown.
useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( wrapperRef.current && !wrapperRef.current.contains(e.target as Node) ) { setIsOpen(false); setSearch(""); setFocusedIndex(-1); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []);
Why mousedown instead of click?
mousedown fires before click. This prevents a race condition where clicking an option inside the dropdown could trigger the outside click handler before the option's click handler fires. It's a subtle but important detail interviewers notice.
Keyboard Navigation
Keyboard support is what separates a toy component from a production one. Here's the full keyboard contract:
| Key | Context | Action |
|---|---|---|
| Enter / Space / ↓ | Trigger focused | Open dropdown |
| ↓ Arrow Down | Dropdown open | Move focus to next option (wraps to first) |
| ↑ Arrow Up | Dropdown open | Move focus to previous option (wraps to last) |
| Enter | Option focused | Toggle selection of focused option |
| Escape | Dropdown open | Close dropdown, reset search and focus |
const handleDropdownKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault(); setFocusedIndex((prev) => prev < filteredOptions.length - 1 ? prev + 1 : 0 ); break; case "ArrowUp": e.preventDefault(); setFocusedIndex((prev) => prev > 0 ? prev - 1 : filteredOptions.length - 1 ); break; case "Enter": e.preventDefault(); if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) { toggleOption(filteredOptions[focusedIndex].value); } break; case "Escape": e.preventDefault(); closeDropdown(); break; } };
Two supporting effects keep the keyboard experience smooth:
// Reset focused index when search changes useEffect(() => { setFocusedIndex(-1); }, [search]); // Scroll focused option into view useEffect(() => { if (focusedIndex >= 0 && listRef.current) { const items = listRef.current.querySelectorAll<HTMLLIElement>( "[role='option']" ); items[focusedIndex]?.scrollIntoView({ block: "nearest" }); } }, [focusedIndex]);
Interview tip
Always call e.preventDefault() on Arrow keys. Without it, the browser scrolls the page and the search input cursor moves, creating a janky experience.
Full Implementation
Here's the complete component with all features wired together. Study how the state, refs, effects, and event handlers compose into a cohesive whole.
"use client"; import { useState, useRef, useEffect, useCallback } from "react"; interface Option { value: string; label: string; } const OPTIONS: Option[] = [ { value: "react", label: "React" }, { value: "vue", label: "Vue" }, { value: "angular", label: "Angular" }, { value: "svelte", label: "Svelte" }, { value: "nextjs", label: "Next.js" }, { value: "nuxt", label: "Nuxt" }, { value: "remix", label: "Remix" }, { value: "astro", label: "Astro" }, { value: "solid", label: "SolidJS" }, { value: "qwik", label: "Qwik" }, ]; export default function MultiSelectDropdownPage() { const [selected, setSelected] = useState<string[]>([]); const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); const [focusedIndex, setFocusedIndex] = useState(-1); const wrapperRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLUListElement>(null); const searchInputRef = useRef<HTMLInputElement>(null); // Outside click detection useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( wrapperRef.current && !wrapperRef.current.contains(e.target as Node) ) { setIsOpen(false); setSearch(""); setFocusedIndex(-1); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); // Reset focus when search changes useEffect(() => { setFocusedIndex(-1); }, [search]); // Scroll focused option into view useEffect(() => { if (focusedIndex >= 0 && listRef.current) { const items = listRef.current .querySelectorAll<HTMLLIElement>("[role='option']"); items[focusedIndex]?.scrollIntoView({ block: "nearest" }); } }, [focusedIndex]); const filteredOptions = OPTIONS.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()) ); const toggleOption = (value: string) => { setSelected((prev) => prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value] ); }; const openDropdown = useCallback(() => { setIsOpen(true); setFocusedIndex(-1); setTimeout(() => searchInputRef.current?.focus(), 0); }, []); const closeDropdown = useCallback(() => { setIsOpen(false); setSearch(""); setFocusedIndex(-1); }, []); const handleDropdownKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault(); setFocusedIndex((prev) => prev < filteredOptions.length - 1 ? prev + 1 : 0 ); break; case "ArrowUp": e.preventDefault(); setFocusedIndex((prev) => prev > 0 ? prev - 1 : filteredOptions.length - 1 ); break; case "Enter": e.preventDefault(); if (focusedIndex >= 0) { toggleOption(filteredOptions[focusedIndex].value); } break; case "Escape": e.preventDefault(); closeDropdown(); break; } }; return ( <div ref={wrapperRef} className="relative"> {/* Trigger */} <button onClick={() => (isOpen ? closeDropdown() : openDropdown())} aria-haspopup="listbox" aria-expanded={isOpen} > {selected.length === 0 ? "Select..." : selected.map((val) => ( <span key={val}> {OPTIONS.find((o) => o.value === val)?.label} <button onClick={(e) => { e.stopPropagation(); setSelected((p) => p.filter((v) => v !== val)); }}>×</button> </span> ))} </button> {/* Dropdown */} {isOpen && ( <div onKeyDown={handleDropdownKeyDown}> <input ref={searchInputRef} value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search..." aria-label="Filter options" autoFocus /> <ul ref={listRef} role="listbox" aria-multiselectable> {filteredOptions.map((option, index) => ( <li key={option.value} role="option" aria-selected={selected.includes(option.value)} onClick={() => toggleOption(option.value)} className={ index === focusedIndex ? "focused" : "" } > {option.label} </li> ))} </ul> </div> )} </div> ); }
Accessibility (ARIA)
Accessibility is not optional — it's a requirement in production and a differentiator in interviews. Here are the ARIA attributes this component needs:
| Element | Attribute | Purpose |
|---|---|---|
| Trigger button | aria-haspopup="listbox" | Tells screen readers this button opens a listbox |
| Trigger button | aria-expanded | Announces whether the dropdown is open or closed |
| Search input | aria-label="Filter options" | Labels the input for screen readers (no visible label) |
| Options list | role="listbox" | Identifies the container as a list of selectable options |
| Options list | aria-multiselectable | Indicates multiple options can be selected |
| Each option | role="option" | Identifies each item as a selectable option |
| Each option | aria-selected | Announces whether the option is currently selected |
| Remove chip button | aria-label="Remove X" | Describes the action for the icon-only × button |
Interview tip
Proactively mention accessibility before the interviewer asks. Say something like: "I'll add ARIA attributes for screen reader support — the trigger needs aria-haspopup and aria-expanded, and the list needs role=listbox with aria-selected on each option." This signals senior-level thinking.
Common Interview Follow-up Questions
After building the component, interviewers probe deeper. Here are the most common follow-ups:
Q:How would you handle hundreds of options?
A: Virtualize the list with react-window or react-virtuoso so only visible options are in the DOM. Add server-side search so the client doesn't download all options upfront. Debounce the search input to avoid filtering on every keystroke.
Q:How would you make this a controlled component?
A: Accept `value` and `onChange` props instead of internal state. The parent owns the selected values and passes them down. The component calls onChange(newSelected) instead of setSelected. This follows the same pattern as native form inputs.
Q:How would you handle async options (fetched from an API)?
A: Add a loading state. Fetch options on mount or when search changes (debounced). Show a spinner in the dropdown while loading. Cache results to avoid re-fetching. Handle errors with a retry option.
Q:What if the dropdown opens upward because there's no space below?
A: Measure the trigger's position relative to the viewport using getBoundingClientRect(). If there's not enough space below, flip the dropdown above the trigger by changing the CSS from top: 100% to bottom: 100%. Libraries like Floating UI handle this automatically.
Q:How would you test this component?
A: Unit tests: render, click trigger, verify dropdown opens. Click option, verify it's selected. Type in search, verify filtering. Press Escape, verify close. Integration: test keyboard navigation end-to-end. Accessibility: use axe-core to check ARIA violations.
Q:How does this differ from a native <select multiple>?
A: Native multi-select has poor UX (Ctrl+click to select multiple, no search, no chips). Custom dropdowns give full control over styling, behavior, and accessibility. The tradeoff is you have to implement keyboard navigation and ARIA yourself.
Q:How would you handle form integration?
A: Add a hidden input (or multiple hidden inputs) with the selected values so the component works with native form submission. Or use React Hook Form / Formik with a custom Controller wrapper that maps selected values to the form state.
Q:What about mobile / touch devices?
A: On mobile, the dropdown should be full-width. Consider using a bottom sheet pattern instead of a dropdown for better thumb reach. The search input should not auto-focus on mobile (it triggers the keyboard unexpectedly). Test with touch events, not just click.
Ready to build it yourself?
We've set up the scaffolding with data and basic UI. Implement outside click, keyboard navigation, and accessibility from scratch.
Built for developers, by developers. Happy coding! 🚀