Combobox
Searchable select dropdown with keyboard navigation, click-outside detection, and filtered options.
Preview
Basic combobox
import { Combobox } from '@/registry/ui-kit/tailwind-combobox/react'
function Example() {
const [value, setValue] = useState('')
return (
<Combobox
options={[
{ value: 'us', label: 'United States' },
{ value: 'uk', label: 'United Kingdom' },
{ value: 'ca', label: 'Canada' },
]}
value={value}
onChange={setValue}
placeholder="Select a country..."
/>
)
}Component API
| Prop | Default | Description |
|---|---|---|
Combobox | — | Searchable select with filtered dropdown, keyboard support, and click-outside detection. |
Source Code
"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
interface ComboboxOption {
value: string;
label: string;
}
interface ComboboxProps {
options: ComboboxOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function Combobox({
options,
value,
onChange,
placeholder = "Search...",
className = "",
}: ComboboxProps) {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selectedOption = options.find((o) => o.value === value);
const filtered = query
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
: options;
// Click outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Reset highlighted index when filtered list changes
useEffect(() => {
setHighlightedIndex(0);
}, [filtered.length]);
const handleSelect = useCallback(
(optionValue: string) => {
onChange(optionValue);
const opt = options.find((o) => o.value === optionValue);
setQuery(opt?.label ?? "");
setIsOpen(false);
},
[onChange, options],
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
setIsOpen(true);
e.preventDefault();
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((i) => (i + 1) % filtered.length);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((i) => (i - 1 + filtered.length) % filtered.length);
break;
case "Enter":
e.preventDefault();
if (filtered[highlightedIndex]) {
handleSelect(filtered[highlightedIndex].value);
}
break;
case "Escape":
setIsOpen(false);
break;
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
if (!isOpen) setIsOpen(true);
};
const handleFocus = () => {
setIsOpen(true);
if (selectedOption && !query) {
setQuery("");
}
};
return (
<div ref={containerRef} className={`relative ${className}`}>
<div className="relative">
{/* Search icon */}
<svg
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
ref={inputRef}
type="text"
value={isOpen ? query : selectedOption?.label ?? query}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full rounded-lg border border-zinc-200 bg-white py-2 pl-9 pr-3 text-sm text-zinc-900 outline-none transition-colors placeholder:text-zinc-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-autocomplete="list"
/>
</div>
{isOpen && filtered.length > 0 && (
<ul
role="listbox"
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-zinc-200 bg-white py-1 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
>
{filtered.map((option, index) => {
const isSelected = option.value === value;
const isHighlighted = index === highlightedIndex;
return (
<li
key={option.value}
role="option"
aria-selected={isSelected}
className={`flex cursor-pointer items-center justify-between px-3 py-2 text-sm ${
isHighlighted ? "bg-zinc-100 dark:bg-zinc-800" : ""
} ${isSelected ? "font-medium text-indigo-600 dark:text-indigo-400" : "text-zinc-700 dark:text-zinc-300"}`}
onClick={() => handleSelect(option.value)}
onMouseEnter={() => setHighlightedIndex(index)}
>
<span>{option.label}</span>
{isSelected && (
<svg
className="h-4 w-4 text-indigo-600 dark:text-indigo-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</li>
);
})}
</ul>
)}
</div>
);
}