Adam Shelley

Web Developer

Back

React
Motion
Tailwind

Search Bar

This is my custom opinionated search bar component.

It is built using React, Framer Motion, and Tailwind CSS.

5 suggestions available.

 

 

Challenge

My main goal with this custom component was to create a search bar that is both easy to use, and satisfying in terms of animations and UX.

 

Usage

  1. Install the dependencies

 

NPM

npm install motion tailwind tailwindmerge clsx

Bun

bun add motion tailwind tailwindmerge clsx
  1. Add the util file for tailwind merge @/utils/cn.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
  1. Copy the source code in your project @/components/searchbar.tsx
"use client";

import * as React from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
import { useTheme } from "next-themes";

// -------------------- Types --------------------

export interface Option {
  id: number;
  label: string;
}

export interface SearchBarProps {
  dropdownOptions?: Option[];
  maxSuggestions?: number;
  placeholder?: string;
  onSelect?: (selectedOption: Option) => void;
  onChange?: (inputValue: string) => void;
  disabled?: boolean;
  minimizable?: boolean;
  showClearButton?: boolean;
  clearButtonStyleClass?: string;
  clearOnSelect?: boolean;
  noResultsMessage?: string;
  filterDebounceTime?: number;
  renderItem?: (item: Option, isSelected: boolean) => React.ReactNode;
  highlightMatches?: boolean;
  highlightMatchesStyles?: string;
  customLoader?: React.ReactNode;
  width?: string;
  height?: string;
  darkMode?: boolean;
}

export interface SuggestionDropdownProps {
  suggestions: Option[];
  onSuggestionClick: (suggestion: Option) => void;
  hasMoreResults?: boolean;
  totalResults?: number;
  selectedIndex?: number;
  searchValue?: string;
  selectedSuggestionId?: number | null;
  noResultsMessage?: string;
  renderItem?: (item: Option, isSelected: boolean) => React.ReactNode;
  highlightMatches?: boolean;
  highlightMatchesStyles?: string;
  customLoader?: React.ReactNode;
  isDarkMode?: boolean;
  setSelectedIndex?: (index: number) => void;
}

// -------------------- Themed Wrapper --------------------

export const ThemedSearchBar: React.FC<Omit<SearchBarProps, "darkMode">> = (
  props
) => {
  const { theme } = useTheme();
  const isDarkMode = theme === "dark";

  return <SearchBar {...props} darkMode={isDarkMode} />;
};

// -------------------- Components --------------------

export const SearchBar: React.FC<SearchBarProps> = ({
  dropdownOptions = [],
  maxSuggestions = 5,
  placeholder,
  onSelect,
  onChange,
  disabled,
  showClearButton,
  clearButtonStyleClass,
  clearOnSelect,
  minimizable,
  noResultsMessage,
  filterDebounceTime = 100,
  renderItem,
  highlightMatches,
  highlightMatchesStyles,
  customLoader,
  width = "400px",
  height = "48px",
  darkMode,
}) => {
  const [isFocused, setIsFocused] = useState(false);
  const inputRef = useRef(null);
  const [searchValue, setSearchValue] = useState("");
  const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
  const [displayValue, setDisplayValue] = useState("");
  const [filteredSuggestions, setFilteredSuggestions] = useState<Option[]>([]);
  const [selectedIndex, setSelectedIndex] = useState(-1);
  const [showSuggestions, setShowSuggestions] = useState(true);
  const [isMinimized, setIsMinimized] = useState(minimizable);
  const [selectedSuggestionId, setSelectedSuggestionId] = useState<
    number | null
  >(null);

  // Detect system dark mode if darkMode prop is not explicitly provided
  const [systemDarkMode, setSystemDarkMode] = useState(false);

  useEffect(() => {
    if (darkMode === undefined) {
      const darkModeMediaQuery = window.matchMedia(
        "(prefers-color-scheme: dark)"
      );
      setSystemDarkMode(darkModeMediaQuery.matches);

      const handleChange = (e: MediaQueryListEvent) => {
        setSystemDarkMode(e.matches);
      };

      darkModeMediaQuery.addEventListener("change", handleChange);
      return () =>
        darkModeMediaQuery.removeEventListener("change", handleChange);
    }
  }, [darkMode]);

  // Use explicit prop if provided, otherwise use detected system setting
  const isDarkMode = darkMode !== undefined ? darkMode : systemDarkMode;

  const filterSuggestions = useCallback(
    (value: string) => {
      if (!dropdownOptions || value.length === 0) return [];

      return dropdownOptions.filter((suggestion) =>
        suggestion.label.toLowerCase().includes(value.toLowerCase())
      );
    },
    [dropdownOptions]
  );

  const onChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
    const value = event.target.value;
    setDisplayValue(value);
    setShowSuggestions(true);

    if (debounceTimerRef.current) {
      clearTimeout(debounceTimerRef.current);
    }

    debounceTimerRef.current = setTimeout(() => {
      setSearchValue(value);
      if (value.length > 0) {
        setFilteredSuggestions(filterSuggestions(value));
        onChange?.(value);
      } else {
        setFilteredSuggestions([]);
      }
      setSelectedIndex(-1);
    }, filterDebounceTime);
  };

  const handleSuggestionClick = (suggestion: Option) => {
    setSelectedSuggestionId(suggestion.id);

    setTimeout(() => {
      setSearchValue(suggestion.label);
      setDisplayValue(suggestion.label);
      setShowSuggestions(false);
      onSelect?.(suggestion);
      setSelectedIndex(-1);

      if (clearOnSelect) {
        requestAnimationFrame(() => {
          setSearchValue("");
          setDisplayValue("");
          setSelectedSuggestionId(null);
        });
      } else {
        setTimeout(() => {
          setSelectedSuggestionId(null);
        }, 300);
      }
    }, 200);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    const suggestionsToDisplay =
      searchValue.length > 0
        ? filteredSuggestions.slice(0, maxSuggestions)
        : dropdownOptions.slice(0, maxSuggestions);

    if (event.key === "Enter") {
      if (selectedIndex >= 0 && selectedIndex < suggestionsToDisplay.length) {
        handleSuggestionClick(suggestionsToDisplay[selectedIndex]);
      } else {
        const selectedSuggestion = filteredSuggestions.find(
          (suggestion) => suggestion.label === searchValue
        );
        if (selectedSuggestion) {
          handleSuggestionClick(selectedSuggestion);
        } else {
          setIsFocused(false);
          const inputElement = inputRef.current as HTMLElement | null;
          if (inputElement) {
            inputElement.animate(
              [
                { transform: "scale(1)" },
                { transform: "scale(1.02)" },
                { transform: "scale(1)" },
              ],
              { duration: filterDebounceTime, easing: "ease-in-out" }
            );
          }
        }
      }
    } else if (event.key === "ArrowDown") {
      event.preventDefault();
      if (suggestionsToDisplay.length > 0) {
        const nextIndex =
          selectedIndex < suggestionsToDisplay.length - 1
            ? selectedIndex + 1
            : 0;
        setSelectedIndex(nextIndex);
        setDisplayValue(suggestionsToDisplay[nextIndex].label);
      }
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      if (suggestionsToDisplay.length > 0) {
        const prevIndex =
          selectedIndex > 0
            ? selectedIndex - 1
            : suggestionsToDisplay.length - 1;
        setSelectedIndex(prevIndex);
        setDisplayValue(suggestionsToDisplay[prevIndex].label);
      }
    } else if (event.key === "Escape") {
      setIsFocused(false);
      setSelectedIndex(-1);
      setDisplayValue(searchValue);
      setShowSuggestions(false);
    } else {
      setShowSuggestions(true);
    }
  };

  useEffect(() => {
    if (searchValue) {
      setFilteredSuggestions(filterSuggestions(searchValue));
    }
  }, [dropdownOptions, filterSuggestions, searchValue]);

  useEffect(() => {
    setDisplayValue(searchValue);
  }, [searchValue]);

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent): void => {
      if (
        inputRef.current &&
        !(inputRef.current as HTMLDivElement).contains(event.target as Node)
      ) {
        setIsFocused(false);
        setDisplayValue(searchValue);
      }
    };

    document.addEventListener("mousedown", handleClickOutside);
    return () => {
      document.removeEventListener("mousedown", handleClickOutside);
      // Clean up any pending debounce timer
      if (debounceTimerRef.current) {
        clearTimeout(debounceTimerRef.current);
      }
    };
  }, [searchValue]);

  const suggestionsToDisplay =
    searchValue.length > 0
      ? filteredSuggestions.slice(0, maxSuggestions)
      : dropdownOptions.slice(0, maxSuggestions);

  const hasMoreResults =
    searchValue.length > 0
      ? filteredSuggestions.length > maxSuggestions
      : dropdownOptions.length > maxSuggestions;

  const clearSearchHandler = () => {
    setSearchValue("");
    setDisplayValue("");
    setShowSuggestions(false);
    setSelectedIndex(-1);
    setIsFocused(false);
  };

  return (
    <AnimatePresence initial={false}>
      <div aria-live="polite" className="sr-only" role="status">
        {suggestionsToDisplay.length > 0
          ? `${suggestionsToDisplay.length} suggestions available.${
              selectedIndex >= 0
                ? ` ${suggestionsToDisplay[selectedIndex]?.label} selected.`
                : ""
            }`
          : searchValue.length > 0
            ? "No suggestions available."
            : ""}
      </div>

      <motion.div
        className={`w-full flex ${
          minimizable ? "justify-start pl-4" : "justify-center"
        } items-center`}
        style={{ height }}
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        key="search-container"
      >
        <motion.div
          ref={inputRef}
          role="combobox"
          aria-expanded={
            isFocused && showSuggestions && suggestionsToDisplay.length > 0
          }
          aria-owns="search-suggestions"
          aria-haspopup="listbox"
          className={`relative flex items-center rounded-lg border box-border 
            ${
              isDarkMode
                ? "bg-zinc-800 !border-zinc-700"
                : "bg-white !border-zinc-200"
            } 
          ${
            isFocused
              ? isDarkMode
                ? "!border-zinc-600"
                : "border-zinc-500"
              : isDarkMode
                ? "!border-zinc-700"
                : "border-zinc-200"
          }`}
          initial={{ width: minimizable ? "48px" : width }}
          animate={{
            width: minimizable
              ? isMinimized
                ? "60px"
                : width
              : isFocused
                ? `calc(${width} + 12px)`
                : width,
            height: minimizable
              ? isMinimized
                ? height
                : height
              : isFocused
                ? height
                : height,
            borderBottom: isFocused
              ? isDarkMode
                ? "border-zinc-700"
                : "border-zinc-200"
              : isDarkMode
                ? "border-zinc-700"
                : "border-zinc-200",
            boxShadow: isFocused
              ? isDarkMode
                ? "0 5px 10px rgba(0, 0, 0, 0.2)"
                : "0 5px 10px rgba(0, 0, 0, 0.05)"
              : "none",
            borderBottomLeftRadius:
              isFocused &&
              showSuggestions &&
              searchValue.length > 0 &&
              dropdownOptions.length > 0
                ? 0
                : "0.375rem",
            borderBottomRightRadius:
              isFocused &&
              showSuggestions &&
              searchValue.length > 0 &&
              dropdownOptions.length > 0
                ? 0
                : "0.375rem",
          }}
          transition={{
            type: "spring",
            stiffness: 300,
            damping: 25,
            originX: minimizable ? 0 : 0.5,
          }}
        >
          <div
            className={cn(
              "flex items-center justify-center",
              minimizable && isMinimized ? "mx-auto" : "ml-3 mr-1"
            )}
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              className={cn(
                "flex-shrink-0",
                isDarkMode ? "text-zinc-400" : "text-gray-400",
                minimizable ? "cursor-pointer" : "",
                isMinimized
                  ? isDarkMode
                    ? "text-zinc-300"
                    : "text-slate-800"
                  : ""
              )}
              style={{
                width: "16px",
                height: "16px",
                minWidth: "16px",
                maxWidth: "16px",
              }}
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
              strokeWidth={2}
              onClick={() => minimizable && setIsMinimized(!isMinimized)}
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
              />
            </svg>
          </div>

          {(!minimizable || !isMinimized) && (
            <>
              <motion.input
                type="text"
                aria-label={placeholder || "Search"}
                aria-autocomplete="list"
                aria-controls="search-suggestions"
                aria-activedescendant={
                  selectedIndex >= 0
                    ? `suggestion-${suggestionsToDisplay[selectedIndex]?.id}`
                    : undefined
                }
                placeholder={placeholder || "Search..."}
                className={`p-3 w-full outline-none relative ${
                  isDarkMode
                    ? "bg-zinc-800 text-zinc-100 placeholder-zinc-400"
                    : "text-zinc-800 placeholder-gray-400"
                }`}
                disabled={disabled}
                onFocus={() => {
                  setIsFocused(true);
                  if (searchValue.length > 0) {
                    setShowSuggestions(true);
                  }
                }}
                onChange={onChangeHandler}
                value={displayValue}
                onKeyDown={handleKeyDown}
                animate={{
                  paddingLeft: "8px",
                  opacity: 1,
                  width: "100%",
                }}
                initial={{ opacity: 0, width: 0 }}
                transition={{ duration: 0.3 }}
                style={{ fontSize: "14px" }}
              />
              <AnimatePresence>
                {showClearButton && searchValue.length ? (
                  <motion.div
                    initial={{ opacity: 0, scale: 0.8 }}
                    animate={{ opacity: 1, scale: 1 }}
                    exit={{ opacity: 0, scale: 0.8 }}
                    transition={{
                      type: "spring",
                      stiffness: 300,
                      damping: 25,
                    }}
                    key="clear-button"
                  >
                    <button
                      className={cn(
                        `rounded-sm p-1 mr-2 transition cursor-pointer text-xs ${
                          isDarkMode
                            ? "bg-zinc-700 hover:bg-zinc-600 text-zinc-200"
                            : "bg-zinc-50 hover:bg-zinc-100 text-zinc-800"
                        }`,
                        clearButtonStyleClass
                      )}
                      onClick={clearSearchHandler}
                    >
                      Clear
                    </button>
                  </motion.div>
                ) : null}
              </AnimatePresence>

              {isFocused &&
                dropdownOptions?.length > 0 &&
                searchValue.length > 0 &&
                showSuggestions && (
                  <SuggestionDropdown
                    suggestions={suggestionsToDisplay}
                    onSuggestionClick={handleSuggestionClick}
                    hasMoreResults={hasMoreResults}
                    noResultsMessage={noResultsMessage}
                    renderItem={renderItem}
                    searchValue={searchValue}
                    totalResults={
                      searchValue.length > 0
                        ? filteredSuggestions.length
                        : dropdownOptions.length
                    }
                    selectedIndex={selectedIndex}
                    selectedSuggestionId={selectedSuggestionId}
                    highlightMatches={highlightMatches}
                    highlightMatchesStyles={highlightMatchesStyles}
                    customLoader={customLoader}
                    isDarkMode={isDarkMode}
                    setSelectedIndex={setSelectedIndex}
                  />
                )}
            </>
          )}
        </motion.div>
      </motion.div>
    </AnimatePresence>
  );
};

const SuggestionDropdown = ({
  suggestions,
  onSuggestionClick,
  hasMoreResults = false,
  totalResults = 0,
  selectedIndex = -1,
  selectedSuggestionId = null,
  searchValue = "",
  noResultsMessage,
  renderItem,
  highlightMatches,
  highlightMatchesStyles,
  customLoader,
  isDarkMode = false,
  setSelectedIndex,
}: SuggestionDropdownProps) => {
  return (
    <AnimatePresence>
      <motion.div
        className={`z-[999] absolute w-full shadow-lg top-full left-0 right-0 rounded-b-xl border border-t-0 box-border overflow-hidden ${
          isDarkMode
            ? "bg-zinc-800 !border-zinc-700"
            : "bg-white !border-zinc-200"
        }`}
        style={{
          top: "calc(100% - 1px)",
          width: "calc(100% + 2px)",
          marginLeft: "-1px",
          transformOrigin: "top",
          fontSize: "14px",
        }}
        initial={{
          opacity: 0,
          height: 0,
          scaleY: 0.8,
        }}
        animate={{
          opacity: selectedSuggestionId ? [1, 0.8, 0] : 1,
          height: "auto",
          scaleY: 1,
        }}
        transition={{
          type: "spring",
          stiffness: 400,
          damping: 30,
          opacity: selectedSuggestionId
            ? { duration: 0.2, times: [0, 0.5, 1] }
            : { duration: 0.15 },
          height: { duration: 0.2 },
        }}
        exit={{
          opacity: 0,
          height: 0,
          scaleY: 0.8,
          transition: {
            duration: 0.15,
            height: { duration: 0.1 },
          },
        }}
      >
        <motion.ul
          className="p-2"
          id="search-suggestions"
          role="listbox"
          aria-label="Search suggestions"
        >
          {suggestions.length > 0 ? (
            <>
              {suggestions.map((suggestion, index) => {
                const isSelected =
                  index === selectedIndex ||
                  selectedSuggestionId === suggestion.id;

                if (renderItem) {
                  return (
                    <motion.div
                      key={suggestion.id || index}
                      role="option"
                      aria-selected={isSelected}
                      id={`suggestion-${suggestion.id}`}
                      tabIndex={0}
                      onFocus={() =>
                        setSelectedIndex && setSelectedIndex(index)
                      }
                      onKeyDown={(e: KeyboardEvent) => {
                        if (e.key === "Enter") {
                          e.preventDefault();
                          onSuggestionClick(suggestion);
                        }
                      }}
                      initial={{ opacity: 0, x: -5 }}
                      animate={{
                        opacity:
                          selectedSuggestionId &&
                          selectedSuggestionId !== suggestion.id
                            ? 0
                            : 1,
                        x: 0,
                        scale: isSelected ? 1.01 : 1,
                        backgroundColor:
                          selectedSuggestionId === suggestion.id
                            ? isDarkMode
                              ? "#2d3748"
                              : "#f3f4f6"
                            : undefined,
                      }}
                      transition={{
                        delay: index * 0.02,
                        duration: 0.15,
                        scale: { duration: 0.2 },
                        opacity: { duration: 0.1 },
                      }}
                      whileTap={{ scale: 0.98 }}
                      onClick={() => onSuggestionClick(suggestion)}
                      className="cursor-pointer"
                    >
                      {renderItem(suggestion, isSelected)}
                    </motion.div>
                  );
                }

                return (
                  <motion.li
                    key={suggestion.id || index}
                    className={`p-2 cursor-pointer rounded-md text-left suggestion-item ${
                      isDarkMode
                        ? `hover:bg-zinc-700 ${
                            isSelected ? "font-medium !bg-zinc-700" : ""
                          }`
                        : `hover:bg-gray-100 ${
                            isSelected ? "font-medium !bg-zinc-100" : ""
                          }`
                    } ${isDarkMode ? "text-zinc-200" : "text-zinc-800"}`}
                    role="option"
                    id={`suggestion-${suggestion.id}`}
                    aria-selected={isSelected}
                    tabIndex={0}
                    onFocus={() => setSelectedIndex && setSelectedIndex(index)}
                    onKeyDown={(e: KeyboardEvent) => {
                      if (e.key === "Enter") {
                        e.preventDefault();
                        onSuggestionClick(suggestion);
                      }
                    }}
                    initial={{ opacity: 0, x: -5 }}
                    animate={{
                      opacity:
                        selectedSuggestionId &&
                        selectedSuggestionId !== suggestion.id
                          ? 0
                          : 1,
                      x: 0,
                      scale: selectedSuggestionId === suggestion.id ? 1.01 : 1,
                      backgroundColor:
                        selectedSuggestionId === suggestion.id
                          ? isDarkMode
                            ? "#2d3748"
                            : "#f3f4f6"
                          : undefined,
                    }}
                    transition={{
                      delay: index * 0.02,
                      duration: 0.15,
                      backgroundColor: { duration: 0.2 },
                      opacity: { duration: 0.1 },
                    }}
                    whileTap={{ scale: 0.98 }}
                    onClick={() => onSuggestionClick(suggestion)}
                  >
                    {highlightMatches ? (
                      <span>
                        {suggestion.label
                          .split(new RegExp(`(${searchValue})`, "i"))
                          .map((part, i) => {
                            return (
                              <span
                                key={i}
                                className={
                                  part.toLowerCase() ===
                                  searchValue.toLowerCase()
                                    ? `${
                                        highlightMatchesStyles
                                          ? highlightMatchesStyles
                                          : isDarkMode
                                            ? "bg-yellow-700"
                                            : "bg-yellow-200"
                                      }`
                                    : ""
                                }
                              >
                                {part}
                              </span>
                            );
                          })}
                      </span>
                    ) : (
                      <span>{suggestion.label}</span>
                    )}
                  </motion.li>
                );
              })}
              {hasMoreResults && (
                <motion.div
                  className={`text-xs p-2 text-center border-t mt-1 ${
                    isDarkMode
                      ? "text-zinc-400 border-zinc-700"
                      : "text-gray-500 border-gray-100"
                  }`}
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                >
                  {`Showing ${suggestions.length} of ${totalResults} results`}
                </motion.div>
              )}
            </>
          ) : (
            <>
              <motion.div
                className={`p-2 text-center ${
                  isDarkMode ? "text-zinc-400" : "text-gray-500"
                }`}
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
              >
                {customLoader}
              </motion.div>

              <motion.div
                className={`p-2 text-center ${
                  isDarkMode ? "text-zinc-400" : "text-gray-500"
                }`}
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
              >
                {noResultsMessage?.length
                  ? noResultsMessage
                  : "No results found"}
              </motion.div>
            </>
          )}
        </motion.ul>
      </motion.div>
    </AnimatePresence>
  );
};

Basic Usage

import { SearchBar } from "@/components/ui/searchbar";

 <SearchBar
    dropdownOptions={[
    { id: 4, label: "Apple" },
    { id: 5, label: "Banana" },
    { id: 6, label: "Orange" },
    { id: 7, label: "Pineapple" },
  ]}  
/>

For a full list of props - see the github link.