Velocity UI
Loading…
Menu

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add expanding-search-bar

Source Code

expanding-search-bar.tsx
'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);
      // Simulate search delay for demo
      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 bg-neutral-100 dark:bg-neutral-950 p-4 rounded-xl font-sans">
      <motion.div
        ref={ref}
        initial={false}
        animate={{
          width: isExpanded ? expandedWidth : 48,
          backgroundColor: isExpanded ? 'var(--background)' : 'transparent',
        }}
        transition={{ type: 'spring', stiffness: 300, damping: 30 }}
        className={`relative flex items-center h-12 rounded-full shadow-sm overflow-hidden border ${
            isExpanded 
            ? 'bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-800' 
            : 'bg-white dark:bg-neutral-900 border-transparent hover:border-neutral-200 dark:hover:border-neutral-800 cursor-pointer'
        }`}
        onClick={!isExpanded ? handleExpand : undefined}
      >
        <div className="flex items-center justify-center min-w-[48px] h-full z-10">
            <AnimatePresence mode="wait">
                {isLoading ? (
                    <motion.div
                        key="loader"
                        initial={{ opacity: 0, rotate: 0 }}
                        animate={{ opacity: 1, rotate: 360 }}
                        exit={{ opacity: 0 }}
                        transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
                    >
                        <Loader2 className="w-5 h-5 text-neutral-500" />
                    </motion.div>
                ) : (
                    <motion.div
                        key="search"
                        initial={{ opacity: 0 }}
                        animate={{ opacity: 1 }}
                        exit={{ opacity: 0 }}
                    >
                         <Search className={`w-5 h-5 ${isExpanded ? 'text-neutral-900 dark:text-white' : 'text-neutral-500'}`} />
                    </motion.div>
                )}
            </AnimatePresence>
        </div>

        <AnimatePresence>
          {isExpanded && (
            <motion.form
              onSubmit={handleSubmit}
              initial={{ opacity: 0, width: 0 }}
              animate={{ opacity: 1, width: '100%' }}
              exit={{ opacity: 0, width: 0 }}
              transition={{ duration: 0.2 }}
              className="flex-1 h-full flex items-center pr-10"
            >
              <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
              initial={{ opacity: 0, scale: 0.5 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.5 }}
              onClick={handleCollapse}
              className="absolute right-3 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.

NameTypeDefaultDescription
placeholderstring"Search components..."Input placeholder text.
onSearch(value: string) => voidundefinedCallback when search input changes.
expandedWidthnumber300Width of the bar when expanded (px).
Context Worth Keeping In Orbit

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.