A circular search button that springs open into a full-width search field, submitting on Enter and collapsing on Escape with focus handed back to the toggle.
Preview
Customize
3 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add expandable-searchWrites components/ui/expandable-search.tsx and components/styles/expandable-search.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ExpandableSearch } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ExpandableSearch />6 );7}Props
Accessibility
Rendered as a <form role="search">; the toggle is a <button type="button"> with aria-label, aria-expanded and aria-controls, and the input carries aria-label="Search". Opening moves focus into the input, Escape collapses and returns focus to the toggle, Enter calls onSearch. The collapsed input is hidden from the tab order. prefers-reduced-motion makes the width change instant.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { motion, useReducedMotion } from "motion/react";5import styles from "../styles/expandable-search.module.css";6 7export interface ExpandableSearchProps {8 /** Expanded width in pixels */9 width?: number;10 /** Input placeholder text */11 placeholder?: string;12 /** Called with the current query when Enter is pressed */13 onSearch?: (query: string) => void;14 /** Start in the expanded state */15 defaultOpen?: boolean;16 /** Optional className merged onto the root */17 className?: string;18}19 20const COLLAPSED = 44;21 22export function ExpandableSearch({23 width = 260,24 placeholder = "Search components…",25 onSearch,26 defaultOpen = false,27 className = "",28}: ExpandableSearchProps) {29 const prefersReducedMotion = useReducedMotion();30 const inputId = React.useId();31 const [open, setOpen] = React.useState(defaultOpen);32 const [query, setQuery] = React.useState("");33 const inputRef = React.useRef<HTMLInputElement>(null);34 const toggleRef = React.useRef<HTMLButtonElement>(null);35 const userToggled = React.useRef(false);36 37 React.useEffect(() => {38 if (!userToggled.current) return;39 if (open) inputRef.current?.focus();40 else toggleRef.current?.focus();41 }, [open]);42 43 const toggle = (next: boolean) => {44 userToggled.current = true;45 setOpen(next);46 };47 48 return (49 <motion.form50 role="search"51 initial={false}52 animate={{ width: open ? Math.max(width, COLLAPSED) : COLLAPSED }}53 transition={prefersReducedMotion ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 28 }}54 onSubmit={(e: React.FormEvent<HTMLFormElement>) => {55 e.preventDefault();56 onSearch?.(query);57 }}58 className={`${styles.root} ${open ? styles.open : ""} ${className}`.trim()}59 >60 <button61 ref={toggleRef}62 type="button"63 aria-label={open ? "Close search" : "Open search"}64 aria-expanded={open}65 aria-controls={inputId}66 onClick={() => toggle(!open)}67 className={styles.toggle}68 >69 {open ? (70 <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">71 <path d="M18 6 6 18M6 6l12 12" />72 </svg>73 ) : (74 <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">75 <circle cx="11" cy="11" r="7" />76 <path d="m20 20-3.5-3.5" />77 </svg>78 )}79 </button>80 <input81 ref={inputRef}82 id={inputId}83 type="search"84 aria-label="Search"85 placeholder={placeholder}86 value={query}87 hidden={!open}88 onChange={(e) => setQuery(e.target.value)}89 onKeyDown={(e) => {90 if (e.key === "Escape") {91 e.preventDefault();92 toggle(false);93 }94 }}95 className={styles.input}96 />97 </motion.form>98 );99}