Custom Select Dropdown in React: Searchable, Multi-Select
Building a custom select dropdown in React that supports search and multi-select without a library. Real code, real trade-offs, no hand-waving.
Why the Native <select> Element Is Not Enough
Honestly, the native HTML <select> element is one of the most frustrating parts of web development. It's unstyable across browsers in any consistent way, it doesn't support search, and forget about multi-select with checkboxes. Designers hand you a Figma spec with a custom dropdown that has grouped options, tag-style selections, and a search input — and the native element simply can't do it.
So you're left with two paths: pull in a library like React Select or Downshift, or build your own. Libraries are fine for most cases, but they add kilobytes to your bundle and often fight with your design system. If you're already using Tailwind v4.0.2 and you've got a cohesive component library going, rolling your own gives you total control.
This article walks you through building a fully functional custom select dropdown in React — searchable, multi-select capable, keyboard-navigable, and accessible. No third-party select library needed. Just React hooks, a little Tailwind, and some careful event handling.
Component Architecture: What You Actually Need to Build
Before writing a single line of code, it helps to think through the pieces. A custom select is really three components working together: a trigger button that shows the current value, a floating dropdown panel, and a list of option items. Each has its own state concerns and event responsibilities.
The trigger needs to show either a placeholder, a single selected label, or a count/tag list for multi-select mode. The dropdown panel needs to handle its own open/close state, position itself relative to the trigger without breaking on scroll, and trap focus when open. The option list needs to filter based on search input and communicate selection back up.
You'll also want a clear separation between controlled and uncontrolled usage. Accept both value + onChange props (controlled) and let the component manage its own state internally if those props aren't passed. This is the same pattern React uses for its own form elements and it makes your component far more reusable. Think about how often you've had to wrap a controlled component in local state just to use it in a form — don't make your users do that.
Building the Base Select Hook
The logic for a dropdown — open state, selection, search filter, keyboard navigation — is complex enough to live in its own custom hook. Keeping it out of the render tree makes it testable and reusable. Here's a useSelect hook that handles single and multi-select modes:
import { useState, useCallback, useRef, useEffect } from 'react';
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface UseSelectProps {
options: SelectOption[];
multiple?: boolean;
defaultValue?: string[];
onChange?: (values: string[]) => void;
}
export function useSelect({
options,
multiple = false,
defaultValue = [],
onChange,
}: UseSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState<string[]>(defaultValue);
const [search, setSearch] = useState('');
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const filteredOptions = options.filter((opt) =>
opt.label.toLowerCase().includes(search.toLowerCase())
);
const toggle = useCallback(
(value: string) => {
setSelected((prev) => {
const next = multiple
? prev.includes(value)
? prev.filter((v) => v !== value)
: [...prev, value]
: [value];
onChange?.(next);
return next;
});
if (!multiple) setIsOpen(false);
},
[multiple, onChange]
);
// Close on outside click
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (
!triggerRef.current?.contains(e.target as Node) &&
!panelRef.current?.contains(e.target as Node)
) {
setIsOpen(false);
setSearch('');
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isOpen]);
return {
isOpen,
setIsOpen,
selected,
search,
setSearch,
filteredOptions,
toggle,
triggerRef,
panelRef,
};
}A few things worth noting here. The toggle function handles both single and multi-select with a single branch. The outside-click handler uses mousedown rather than click — this matters because click fires after blur, which can cause flickering if you're managing focus. And search resets to an empty string when the panel closes so it doesn't carry stale state into the next open.
Rendering the Dropdown Panel with Tailwind
Now the render layer. The panel needs to position itself absolutely below the trigger, handle overflow clipping, and show a search input at the top. With Tailwind v4.0.2, the @starting-style trick for entry animations doesn't have full browser support yet, so a simple opacity + scale transition via data-open attribute is cleaner.
import { useSelect, SelectOption } from './useSelect';
interface SelectProps {
options: SelectOption[];
multiple?: boolean;
placeholder?: string;
onChange?: (values: string[]) => void;
}
export function Select({
options,
multiple = false,
placeholder = 'Select an option',
onChange,
}: SelectProps) {
const {
isOpen,
setIsOpen,
selected,
search,
setSearch,
filteredOptions,
toggle,
triggerRef,
panelRef,
} = useSelect({ options, multiple, onChange });
const displayLabel = selected.length === 0
? placeholder
: multiple
? `${selected.length} selected`
: options.find((o) => o.value === selected[0])?.label ?? placeholder;
return (
<div className="relative w-full">
<button
ref={triggerRef}
type="button"
onClick={() => setIsOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-2.5 rounded-lg border border-white/10 bg-white/5 text-sm text-left focus:outline-none focus:ring-2 focus:ring-violet-500"
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span className={selected.length === 0 ? 'text-white/40' : 'text-white'}>
{displayLabel}
</span>
<svg
className={`w-4 h-4 text-white/40 transition-transform duration-200 ${
isOpen ? 'rotate-180' : ''
}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div
ref={panelRef}
role="listbox"
aria-multiselectable={multiple}
className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-[#111] shadow-xl overflow-hidden"
>
<div className="p-2 border-b border-white/10">
<input
autoFocus
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search..."
className="w-full px-3 py-1.5 rounded-md bg-white/5 text-sm text-white placeholder:text-white/30 focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
</div>
<ul className="max-h-56 overflow-y-auto py-1">
{filteredOptions.length === 0 ? (
<li className="px-4 py-2 text-sm text-white/30">No results</li>
) : (
filteredOptions.map((opt) => {
const isSelected = selected.includes(opt.value);
return (
<li
key={opt.value}
role="option"
aria-selected={isSelected}
onClick={() => !opt.disabled && toggle(opt.value)}
className={`flex items-center gap-2 px-4 py-2 text-sm cursor-pointer transition-colors ${
opt.disabled
? 'opacity-30 cursor-not-allowed'
: 'hover:bg-white/5'
} ${isSelected ? 'text-violet-400' : 'text-white/80'}`}
>
{multiple && (
<span
className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
isSelected
? 'bg-violet-600 border-violet-600'
: 'border-white/20'
}`}
>
{isSelected && (
<svg className="w-2.5 h-2.5 text-white" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round"
strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</span>
)}
{opt.label}
</li>
);
})
)}
</ul>
</div>
)}
</div>
);
}The max-h-56 with overflow-y-auto on the list gives you scrollability without the panel growing to fill the viewport. The gap-2 on option items (8px gap) keeps the checkbox and label nicely spaced. The bg-[#111] background with border-white/10 pairs well with both light glassmorphism backgrounds and dark mode layouts — check out what glassmorphism actually is if you're building a UI system around that aesthetic.
Keyboard Navigation and Accessibility
Can your users actually tab through the dropdown and select options without a mouse? If not, you've built something that's going to fail accessibility audits and frustrate power users. Keyboard nav isn't optional.
The ARIA pattern for a listbox requires role="listbox" on the list, role="option" on each item, aria-selected on each item, and aria-haspopup="listbox" plus aria-expanded on the trigger. We've already added those in the render code above. What we still need is keyboard handling: ArrowDown/ArrowUp to move focus between options, Enter to select, Escape to close, and Home/End to jump to the first and last items.
Add this onKeyDown handler to the trigger button and hook it into a focusedIndex state tracked inside useSelect. When the panel is open and focus is in the search input, ArrowDown should move focus into the list. This is the part most tutorials skip — the transition from search input to list navigation is where keyboard users get stuck. A useRef array of option elements combined with element.focus() is the most reliable approach across browsers.
Multi-Select Tag Display
Showing a count like "3 selected" is fine for compact layouts, but most product UIs want to show the actual selected values as removable tags inside the trigger. This changes the trigger from a simple <button> to a flex container with tag chips and an input field for searching.
The trick is that the trigger becomes a composite element. The outer div gets the border and focus-within ring styling. Inside you render tag chips for each selected value — each chip has an × button that calls toggle(value) to deselect. After the chips, you drop in an <input> that handles search. When the input is focused, the dropdown opens. This is exactly how React Select's multi-mode works, but now it's your code and your styles.
One gotcha: clicking the × on a tag fires a click event that bubbles up and can re-open the dropdown if your trigger has an onClick. Stop propagation on the tag remove button's click handler. Also set min-w-[4rem] on the input so it doesn't collapse to zero width when there are many tags. If you're building a bunch of form controls like this, pairing them with an animated button component for submit actions creates a really cohesive form experience.
Positioning the Panel Without Libraries
Absolute positioning works fine until the trigger is near the bottom of the viewport and the panel gets clipped. The real solution is a small bit of logic that checks available space and flips the panel above the trigger when needed. You don't need Floating UI or Popper.js for this — a useEffect that reads getBoundingClientRect() is enough.
useEffect(() => {
if (!isOpen || !triggerRef.current || !panelRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const panelHeight = panelRef.current.offsetHeight;
const spaceBelow = window.innerHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
if (spaceBelow < panelHeight && spaceAbove > panelHeight) {
panelRef.current.style.bottom = `${triggerRef.current.offsetHeight + 4}px`;
panelRef.current.style.top = 'auto';
} else {
panelRef.current.style.top = `${triggerRef.current.offsetHeight + 4}px`;
panelRef.current.style.bottom = 'auto';
}
}, [isOpen]);The 4px offset matches a mt-1 equivalent in raw pixels. Run this in a useEffect that fires whenever isOpen becomes true. It won't win any awards for elegance, but it handles 95% of real-world cases without pulling in a positioning library. If you're building something with many dropdowns on a scrollable page, then yes, reach for Floating UI — it handles scroll containers and nested positioning that this snippet doesn't.
Integrating with React Hook Form and Styling Variants
Most real applications use React Hook Form or Formik. The custom select needs to work with them. The simplest approach is the Controller wrapper — React Hook Form's <Controller> passes field.value, field.onChange, and field.onBlur as render props, and you just wire those to your component's props.
For styling variants, keep them as a variant prop with a string union type: 'default' | 'ghost' | 'glass'. The glass variant uses bg-white/5 backdrop-blur-md border-white/10 — that rgba(255,255,255,0.05) background with backdrop blur is what gives it the frosted look that pairs well with glassmorphism layouts. The ghost variant strips the background entirely and just keeps the border.
If you're building a complete UI system, this select component slots in naturally alongside other interactive pieces. An animated tabs component handles navigation context switching, while this select handles form-level choice — they share the same visual language but serve different UX roles. Consistency in border radius (8px throughout), focus ring color (violet-500 at 2px), and transition duration (200ms) is what makes a component library feel intentional rather than assembled from random Stack Overflow answers.
FAQ
Wrap it with React Hook Form's <Controller> component. Pass field.value to the value prop and field.onChange to the onChange prop. Make sure your component accepts an array of strings for multi-select mode and a single string (or array with one item) for single-select.
This usually happens when a blur handler on the trigger closes the panel before the click on the search input registers. Use onMouseDown with e.preventDefault() on the panel instead of relying on blur/click ordering — mousedown fires before blur and prevents the trigger from losing focus prematurely.
Virtualize the list with a library like TanStack Virtual. Render only the visible rows inside a container with a fixed height. The useSelect hook's search filtering still works the same — just pass filteredOptions to the virtualizer instead of rendering all items directly.
Yes. Change your options data structure to include a group field or use a nested array format like { label: string, options: SelectOption[] }[]. In the render, map over groups first, render a non-interactive group header with role="presentation" inside the listbox, then render the group's options under it.
In the positioning useEffect, always set bottom to the trigger height plus 4px offset, and set top to auto. The flip logic only overrides this when there's more space below than above. You can also expose a placement prop ('top' | 'bottom' | 'auto') and skip the measurement logic entirely for fixed placements.
The outer list gets role="listbox" and aria-multiselectable="true". Each option gets role="option" and aria-selected set to true or false. The trigger button needs aria-haspopup="listbox" and aria-expanded. The search input inside the panel does not get a listbox role — it's just a regular input that filters what the listbox shows.