Search expand
A search icon that springs open into a full search bar and reveals a dropdown of previous searches.
Source code
Copy and paste into your projectSearchExpand.tsx
"use client"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";import { History, Search, X } from "lucide-react";import { useEffect, useRef, useState, type FormEvent } from "react"; type Props = { previousSearches?: string[]; placeholder?: string; onSearch?: (query: string) => void; className?: string;}; const defaultPreviousSearches = [ "spring animation", "image modal", "emoji picker", "hold to delete",]; const COLLAPSED_WIDTH = 48; // matches size-12 of the icon buttonconst EXPANDED_WIDTH = 280;const MAX_RECENTS = 6; export default function SearchExpand({ previousSearches = defaultPreviousSearches, placeholder = "Search interactions…", onSearch, className = "",}: Props) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [dropdownVisible, setDropdownVisible] = useState(false); const [recents, setRecents] = useState<string[]>(previousSearches); const containerRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLInputElement>(null); const triggerRef = useRef<HTMLButtonElement>(null); const reduceMotion = useReducedMotion(); const trimmed = query.trim().toLowerCase(); const matches = trimmed ? recents.filter((term) => term.toLowerCase().includes(trimmed)) : recents; const openSearch = () => { setOpen(true); setDropdownVisible(true); }; const closeSearch = () => { setOpen(false); setDropdownVisible(false); }; // Focus the input once the bar has opened useEffect(() => { if (open) inputRef.current?.focus(); }, [open]); // Escape closes (and returns focus to the icon); outside clicks collapse useEffect(() => { if (!open) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { closeSearch(); window.setTimeout(() => triggerRef.current?.focus(), 0); } }; const onPointerDown = (event: PointerEvent) => { if (!containerRef.current?.contains(event.target as Node)) closeSearch(); }; document.addEventListener("keydown", onKeyDown); document.addEventListener("pointerdown", onPointerDown); return () => { document.removeEventListener("keydown", onKeyDown); document.removeEventListener("pointerdown", onPointerDown); }; }, [open]); // Move a term to the front of the recents list (deduped, capped) const remember = (term: string) => { setRecents((current) => [term, ...current.filter((item) => item !== term)].slice(0, MAX_RECENTS), ); }; const choose = (term: string) => { setQuery(term); remember(term); onSearch?.(term); setDropdownVisible(false); inputRef.current?.focus(); }; const handleSubmit = (event: FormEvent) => { event.preventDefault(); const value = query.trim(); if (!value) return; remember(value); onSearch?.(value); setDropdownVisible(false); }; return ( <div ref={containerRef} className={`relative ${className}`}> <motion.div initial={false} animate={{ width: open ? EXPANDED_WIDTH : COLLAPSED_WIDTH }} transition={ reduceMotion ? { duration: 0.15 } : open ? { type: "spring", stiffness: 420, damping: 34 } : { type: "spring", stiffness: 420, damping: 34, delay: 0.1 } } className="relative flex h-12 items-center overflow-hidden rounded-full bg-white shadow-[0_5px_12px_rgba(0,0,0,0.04)] dark:bg-neutral-900 dark:shadow-black/20" > {/* Border lives on an overlay so the content box stays exactly 48×48 when collapsed, keeping the icon perfectly centered */} <span aria-hidden className="pointer-events-none absolute inset-0 rounded-full border border-neutral-200 dark:border-neutral-700" /> <motion.button ref={triggerRef} type="button" onClick={() => (open ? inputRef.current?.focus() : openSearch())} aria-label={open ? "Search" : "Open search"} aria-expanded={open} whileHover={reduceMotion ? undefined : { scale: 1.04 }} transition={{ type: "spring", stiffness: 500, damping: 28, mass: 0.5 }} className="grid size-12 shrink-0 cursor-pointer place-items-center rounded-full text-neutral-500 transition-colors hover:text-neutral-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 dark:text-neutral-400 dark:hover:text-white dark:focus-visible:ring-neutral-500" > <Search size={18} aria-hidden /> </motion.button> <AnimatePresence> {open && ( <motion.form onSubmit={handleSubmit} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, x: -6, filter: "blur(3px)" }} animate={{ opacity: 1, x: 0, filter: "blur(0px)" }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, x: -6, filter: "blur(3px)" }} transition={{ duration: 0.16, ease: "easeOut" }} className="flex min-w-0 flex-1 items-center gap-1 pr-2" > <input ref={inputRef} value={query} onChange={(event) => { setQuery(event.target.value); setDropdownVisible(true); }} onFocus={() => setDropdownVisible(true)} placeholder={placeholder} aria-label="Search" className="w-full min-w-0 bg-transparent text-sm text-neutral-800 outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-500" /> {query && ( <button type="button" onClick={() => { setQuery(""); setDropdownVisible(true); inputRef.current?.focus(); }} aria-label="Clear search" className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200" > <X size={13} aria-hidden /> </button> )} </motion.form> )} </AnimatePresence> </motion.div> <AnimatePresence> {open && dropdownVisible && matches.length > 0 && ( <motion.div role="listbox" aria-label="Previous searches" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0, transition: reduceMotion ? { duration: 0.15 } : { y: { type: "spring", stiffness: 480, damping: 32, mass: 0.5, delay: 0.12 }, opacity: { duration: 0.16, ease: "easeOut", delay: 0.12 }, }, }} exit={{ opacity: 0, y: reduceMotion ? 0 : -8, transition: reduceMotion ? { duration: 0.15 } : { y: { type: "spring", stiffness: 480, damping: 32, mass: 0.5 }, opacity: { duration: 0.14, ease: "easeIn" }, }, }} className="absolute left-0 top-[calc(100%+8px)] z-50 flex w-[280px] flex-col overflow-hidden rounded-xl border border-neutral-100/80 bg-white p-1.5 shadow-xl dark:border-neutral-700 dark:bg-neutral-900" > <p className="px-2.5 pb-1 pt-1.5 text-[11px] font-medium text-neutral-400 dark:text-neutral-500"> {trimmed ? "Matches" : "Previous searches"} </p> <ul> {matches.map((term) => ( <li key={term}> <button type="button" role="option" aria-selected={term === query} onClick={() => choose(term)} className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm text-neutral-600 transition-colors duration-150 ease-out hover:bg-neutral-100 focus:outline-none focus-visible:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:focus-visible:bg-neutral-800" > <History size={14} className="shrink-0 text-neutral-400 dark:text-neutral-500" aria-hidden /> <span className="truncate">{term}</span> </button> </li> ))} </ul> </motion.div> )} </AnimatePresence> </div> );}