ReactAccessibilityKeyboard NavigationRefsEvent Handling

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.

30 min read8 sections
01

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.

02

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.

Visual structuretext
┌─────────────────────────────────────────┐
│ [React ×] [Vue ×] [Svelte ×]       ▼  │  ← Trigger (chips + chevron)
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│  🔍 Search...                           │  ← Search input
├─────────────────────────────────────────┤
Select All                  Clear All  │  ← Actions bar
├─────────────────────────────────────────┤
│  ☑ React
│  ☑ Vue                                  │  ← Options list
│  ☐ Angular                              │     (scrollable)
│  ☑ Svelte
│  ☐ Next.js
│  ...                                    │
└─────────────────────────────────────────┘
03

State Design

The component needs five pieces of state. Keeping state minimal and deriving everything else is key.

StateTypePurpose
selectedstring[]Array of selected option values
isOpenbooleanWhether the dropdown panel is visible
searchstringCurrent search/filter text
focusedIndexnumberIndex of keyboard-focused option (-1 = none)
State + refs setuptypescript
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.

04

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.

1

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'.

2

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.

3

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.

Outside click detectiontypescript
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.

05

Keyboard Navigation

Keyboard support is what separates a toy component from a production one. Here's the full keyboard contract:

KeyContextAction
Enter / Space / ↓Trigger focusedOpen dropdown
↓ Arrow DownDropdown openMove focus to next option (wraps to first)
↑ Arrow UpDropdown openMove focus to previous option (wraps to last)
EnterOption focusedToggle selection of focused option
EscapeDropdown openClose dropdown, reset search and focus
Keyboard handlertypescript
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:

Focus management effectstypescript
// 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.

06

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.

multi-select-dropdown/page.tsxtypescript
"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>
  );
}
07

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:

ElementAttributePurpose
Trigger buttonaria-haspopup="listbox"Tells screen readers this button opens a listbox
Trigger buttonaria-expandedAnnounces whether the dropdown is open or closed
Search inputaria-label="Filter options"Labels the input for screen readers (no visible label)
Options listrole="listbox"Identifies the container as a list of selectable options
Options listaria-multiselectableIndicates multiple options can be selected
Each optionrole="option"Identifies each item as a selectable option
Each optionaria-selectedAnnounces whether the option is currently selected
Remove chip buttonaria-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.

08

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