All components

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.

Open in the catalogue

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

index.tsx
"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)}
    </>
  );
}

Similar components