Expanding Search Bar
A minimal search icon that expands into a full input field with spring physics. Collapsed state shows only the icon with no background box; expanded state reveals border, input, clear button, and optional loading spinner.
Preview
Live interactive preview.
Installation
Add this component to your project using the CLI:
npx -y vui-registry-cli-v1@latest add expanding-search-barSource Code
'use client'
import { useState, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Search, X, Loader2 } from 'lucide-react'
interface ExpandingSearchBarProps {
placeholder?: string
onSearch?: (value: string) => void
expandedWidth?: number
}
export default function ExpandingSearchBar({
placeholder = 'Search components...',
onSearch,
expandedWidth = 300,
}: ExpandingSearchBarProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [inputValue, setInputValue] = useState('')
const [isLoading, setIsLoading] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setIsExpanded(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (inputValue.trim()) {
setIsLoading(true)
onSearch?.(inputValue)
setTimeout(() => setIsLoading(false), 1500)
}
}
const handleExpand = () => {
setIsExpanded(true)
setTimeout(() => inputRef.current?.focus(), 100)
}
const handleCollapse = (e: React.MouseEvent) => {
e.stopPropagation()
setIsExpanded(false)
setInputValue('')
}
return (
<div className="flex items-center justify-center h-32 w-full p-4 rounded-xl font-sans">
<motion.div
ref={ref}
initial={false}
animate={{ width: isExpanded ? expandedWidth : 40 }}
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
className={`relative flex items-center h-10 overflow-hidden ${
isExpanded
? 'rounded-full border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm'
: 'rounded-full'
}`}
onClick={!isExpanded ? handleExpand : undefined}
>
<button
type="button"
onClick={!isExpanded ? handleExpand : undefined}
className="flex items-center justify-center w-10 h-10 shrink-0 z-10 text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors"
aria-label="Open search"
>
<AnimatePresence mode="wait">
{isLoading ? (
<motion.div
key="loader"
initial={{ opacity: 0 }}
animate={{ opacity: 1, rotate: 360 }}
exit={{ opacity: 0 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
>
<Loader2 className="w-5 h-5" />
</motion.div>
) : (
<motion.div key="search" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<Search className="w-5 h-5" />
</motion.div>
)}
</AnimatePresence>
</button>
<AnimatePresence>
{isExpanded && (
<motion.form
onSubmit={handleSubmit}
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: 'auto' }}
exit={{ opacity: 0, width: 0 }}
transition={{ duration: 0.2 }}
className="flex-1 h-full flex items-center pr-10 min-w-0"
>
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className="w-full h-full bg-transparent outline-none text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400"
placeholder={placeholder}
/>
</motion.form>
)}
</AnimatePresence>
<AnimatePresence>
{isExpanded && (
<motion.button
type="button"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
onClick={handleCollapse}
className="absolute right-2 p-1 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
>
<X className="w-4 h-4 text-neutral-500" />
</motion.button>
)}
</AnimatePresence>
</motion.div>
</div>
)
}
Dependencies
framer-motion: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| placeholder | string | "Search components..." | Input placeholder text. |
| onSearch | (value: string) => void | undefined | Callback when search input changes. |
| expandedWidth | number | 300 | Width of the bar when expanded (px). |
Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.
I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.
I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.