Searchable Dropdown
Searchable Dropdown is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
forms
What it is
Searchable Dropdown is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i motion lucide-react
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Searchable Dropdown
"use client";
import { ChevronDown, Search, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
const ROTATION_ANGLE_OPEN = 180;
export interface SearchableDropdownItem {
description?: string;
icon?: React.ReactNode;
id: string | number;
label: string;
}
export interface SearchableDropdownProps {
className?: string;
emptyMessage?: string;
items: SearchableDropdownItem[];
label: string;
onChange?: (item: SearchableDropdownItem) => void;
placeholder?: string;
}
export default function SearchableDropdown({
label,
items,
onChange,
placeholder = "Search...",
emptyMessage = "No results found",
className = "",
}: SearchableDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] =
useState<SearchableDropdownItem | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const portalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [position, setPosition] = useState({ left: 0, top: 0, width: 0 });
const shouldReduceMotion = useReducedMotion();
const filteredItems = useMemo(() => {
const trimmedQuery = searchQuery.trim();
if (!trimmedQuery) {
return items;
}
// Cache lowercase query to avoid repeated calls
const query = trimmedQuery.toLowerCase();
const itemsLength = items.length;
const results: typeof items = [];
// Early exit optimization: use for loop instead of filter for better performance
for (let i = 0; i < itemsLength; i++) {
const item = items[i];
const itemLabel = item.label.toLowerCase();
const description = item.description?.toLowerCase();
if (itemLabel.includes(query) || description?.includes(query)) {
results.push(item);
}
}
return results;
}, [items, searchQuery]);
const handleItemSelect = (item: SearchableDropdownItem) => {
setSelectedItem(item);
setIsOpen(false);
setSearchQuery("");
onChange?.(item);
};
const handleClearSearch = () => {
setSearchQuery("");
inputRef.current?.focus();
};
const handleToggle = () => {
if (!isOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setPosition({
left: rect.left,
top: rect.bottom + 4,
width: rect.width,
});
}
setIsOpen(!isOpen);
if (isOpen) {
setSearchQuery("");
} else {
setTimeout(() => inputRef.current?.focus(), 100);
}
};
// Update position on scroll/resize when open
useEffect(() => {
if (!(isOpen && buttonRef.current)) {
return;
}
const updatePosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setPosition({
left: rect.left,
top: rect.bottom + 4,
width: rect.width,
});
}
};
window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition);
return () => {
window.removeEventListener("scroll", updatePosition, true);
window.removeEventListener("resize", updatePosition);
};
}, [isOpen]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
isOpen &&
dropdownRef.current &&
!dropdownRef.current.contains(target) &&
portalRef.current &&
!portalRef.current.contains(target)
) {
setIsOpen(false);
setSearchQuery("");
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
// Keyboard navigation with arrow keys, enter, and escape
const [focusedIndex, setFocusedIndex] = useState(-1);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen) {
// Open dropdown on Enter or Space when button is focused
if (
(event.key === "Enter" || event.key === " ") &&
document.activeElement === buttonRef.current
) {
event.preventDefault();
handleToggle();
}
return;
}
if (event.key === "Escape") {
setIsOpen(false);
setSearchQuery("");
setFocusedIndex(-1);
buttonRef.current?.focus();
} else if (event.key === "ArrowDown") {
event.preventDefault();
setFocusedIndex((prev) =>
prev < filteredItems.length - 1 ? prev + 1 : 0
);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setFocusedIndex((prev) =>
prev > 0 ? prev - 1 : filteredItems.length - 1
);
} else if (event.key === "Enter" && focusedIndex >= 0) {
event.preventDefault();
const item = filteredItems[focusedIndex];
if (item) {
handleItemSelect(item);
}
} else if (event.key === "Home") {
event.preventDefault();
setFocusedIndex(0);
} else if (event.key === "End") {
event.preventDefault();
setFocusedIndex(filteredItems.length - 1);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
// biome-ignore lint/correctness/useExhaustiveDependencies: Handlers are stable via closure
}, [isOpen, filteredItems, focusedIndex, handleItemSelect, handleToggle]);
// Reset focused index when items change
useEffect(() => {
setFocusedIndex(-1);
}, []);
const dropdownContent = (
<AnimatePresence>
{isOpen ? (
<div ref={portalRef}>
<motion.div
animate={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 1, scaleY: 1, y: 0 }
}
className="fixed z-50 origin-top overflow-hidden rounded-lg border bg-background/95 shadow-lg backdrop-blur-md"
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
scaleY: 0.8,
transition: { duration: 0.15 },
y: -10,
}
}
initial={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 0, scaleY: 0.8, y: -10 }
}
style={{
left: `${position.left}px`,
top: `${position.top}px`,
width: `${position.width}px`,
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
damping: 30,
duration: 0.25,
mass: 0.8,
stiffness: 400,
type: "spring" as const,
}
}
>
{/* Search Input */}
<div className="relative border-b p-2">
<motion.div
animate={
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, x: 0 }
}
className="relative"
initial={
shouldReduceMotion ? { opacity: 1 } : { opacity: 0, x: -10 }
}
transition={
shouldReduceMotion
? { duration: 0 }
: {
damping: 25,
delay: 0.05,
duration: 0.2,
stiffness: 400,
type: "spring" as const,
}
}
>
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
aria-autocomplete="list"
aria-controls="dropdown-items"
aria-expanded={isOpen}
aria-label="Search dropdown items"
className="w-full rounded-md border bg-transparent py-2 pr-8 pl-9 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onChange={(e) => {
setSearchQuery(e.target.value);
setFocusedIndex(-1);
}}
placeholder={placeholder}
ref={inputRef}
role="combobox"
type="text"
value={searchQuery}
/>
<AnimatePresence>
{searchQuery ? (
<motion.button
animate={{ opacity: 1 }}
aria-label="Clear search"
className="absolute top-1/2 right-2 min-h-[44px] min-w-[44px] -translate-y-1/2 rounded-full p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
onClick={handleClearSearch}
transition={{
damping: 25,
stiffness: 400,
type: "spring" as const,
}}
type="button"
>
<X aria-hidden="true" className="h-4 w-4" />
</motion.button>
) : null}
</AnimatePresence>
</motion.div>
</div>
{/* Items List */}
<ul
aria-label="Dropdown options"
className="max-h-60 overflow-y-auto py-2"
id="dropdown-items"
>
<AnimatePresence mode="popLayout">
{filteredItems.length > 0 ? (
filteredItems.map((item, index) => (
<motion.li
animate={
shouldReduceMotion
? { opacity: 1 }
: { filter: "blur(0px)", opacity: 1, x: 0 }
}
aria-selected={
selectedItem?.id === item.id || index === focusedIndex
}
className="block"
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: { filter: "blur(4px)", opacity: 0, x: -10 }
}
initial={
shouldReduceMotion
? { opacity: 1 }
: { filter: "blur(4px)", opacity: 0, x: -10 }
}
key={item.id}
layout
role="option"
transition={
shouldReduceMotion
? { duration: 0 }
: {
damping: 28,
delay: index * 0.02,
duration: 0.2,
mass: 0.6,
stiffness: 400,
type: "spring" as const,
}
}
>
<button
aria-label={`${item.label}${item.description ? `, ${item.description}` : ""}`}
className={`flex min-h-[44px] w-full items-center px-4 py-2 text-left text-sm transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
selectedItem?.id === item.id
? "font-medium text-brand"
: ""
} ${index === focusedIndex ? "bg-muted" : ""}`}
onClick={() => handleItemSelect(item)}
onMouseEnter={() => setFocusedIndex(index)}
type="button"
>
{item.icon ? (
<span className="mr-3 shrink-0">{item.icon}</span>
) : null}
<div className="min-w-0 flex-1">
<span className="block truncate">{item.label}</span>
{item.description ? (
<span className="block truncate text-muted-foreground text-xs">
{item.description}
</span>
) : null}
</div>
{selectedItem?.id === item.id && (
<motion.span
animate={shouldReduceMotion ? {} : { scale: 1 }}
className="ml-2 shrink-0"
initial={shouldReduceMotion ? {} : { scale: 0 }}
transition={
shouldReduceMotion
? { duration: 0 }
: {
damping: 25,
duration: 0.2,
mass: 0.5,
stiffness: 400,
type: "spring" as const,
}
}
>
<svg
className="h-4 w-4 text-brand"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<title>Selected</title>
<path
d="M5 13l4 4L19 7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</motion.span>
)}
</button>
</motion.li>
))
) : (
<motion.li
animate={{ opacity: 1 }}
className="px-4 py-8 text-center text-muted-foreground text-sm"
initial={
shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }
}
transition={
shouldReduceMotion
? { duration: 0 }
: {
damping: 25,
duration: 0.2,
stiffness: 400,
type: "spring" as const,
}
}
>
{emptyMessage}
</motion.li>
)}
</AnimatePresence>
</ul>
</motion.div>
</div>
) : null}
</AnimatePresence>
);
return (
<>
<div className={`relative inline-block ${className}`} ref={dropdownRef}>
<button
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={selectedItem ? `${label}: ${selectedItem.label}` : label}
className="flex min-h-[44px] w-full cursor-pointer items-center justify-between gap-2 rounded-lg border bg-background px-4 py-2 text-left transition-colors hover:bg-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
id="dropdown-button"
onClick={handleToggle}
ref={buttonRef}
type="button"
>
<span className="block truncate">
{String(selectedItem ? selectedItem.label : label)}
</span>
<motion.div
animate={{ rotate: isOpen ? ROTATION_ANGLE_OPEN : 0 }}
transition={
shouldReduceMotion
? { duration: 0 }
: {
damping: 25,
duration: 0.2,
stiffness: 400,
type: "spring" as const,
}
}
>
<ChevronDown className="h-4 w-4" />
</motion.div>
</button>
</div>
{typeof window !== "undefined" &&
createPortal(dropdownContent, document.body)}
</>
);
}