Morphing Search
A circular search button that morphs into a text field: the same element grows and reshapes via a layout animation, rather than a new one popping in beside it.
Try searching for something
"use client";
import { useState } from "react";
import { MorphingSearch } from "@/components/motion/morphing-search";
export function MorphingSearchPreview() {
const [lastQuery, setLastQuery] = useState<string | null>(null);
return (
<div className="flex flex-col items-center gap-3">
<MorphingSearch placeholder="Search components..." onSearch={setLastQuery} />
<p className="h-5 text-sm text-muted-foreground">
{lastQuery ? `Searched for "${lastQuery}"` : "Try searching for something"}
</p>
</div>
);
}
"use client";
// easeui.dev/components/motion/morphing-search
import { Search, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface MorphingSearchProps {
placeholder?: string;
onSearch?: (query: string) => void;
className?: string;
}
/**
* A circular search button that morphs into a text field: the same element
* grows and reshapes via a layout animation, rather than a new one popping
* in beside it. Closes on Escape, on submit, or on an outside click.
*/
export function MorphingSearch({ placeholder = "Search...", onSearch, className }: MorphingSearchProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKeyDown);
};
}, [open]);
const close = () => {
setOpen(false);
setQuery("");
};
return (
<div ref={rootRef} className={cn("inline-flex", className)}>
<motion.div
layout
transition={SPRING_PANEL}
style={{ borderRadius: 9999 }}
className="flex h-11 items-center overflow-hidden bg-card shadow-[0_0_0_1px_var(--border)]"
>
<AnimatePresence mode="popLayout" initial={false}>
{open ? (
<motion.form
key="form"
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.1, duration: 0.15 } }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
onSubmit={(event) => {
event.preventDefault();
onSearch?.(query);
}}
className="flex items-center gap-1 pl-4 pr-1.5"
>
<Search aria-hidden="true" className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
className="h-11 w-56 min-w-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
aria-label="Close search"
onClick={close}
className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none after:absolute after:-inset-1 transition-colors duration-150 hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</motion.form>
) : (
<motion.button
key="trigger"
layout
type="button"
aria-label="Search"
onClick={() => setOpen(true)}
// `layout` keeps the icon from stretching as the shell resizes
// around it; the delay also hides it until the shell has
// mostly finished shrinking, so it never appears mid-squash.
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.18, duration: 0.1 } }}
exit={{ opacity: 0, transition: { duration: 0 } }}
className="flex h-11 w-11 shrink-0 items-center justify-center text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<Search className="h-[18px] w-[18px]" />
</motion.button>
)}
</AnimatePresence>
</motion.div>
</div>
);
}
Installation
$ bunx --bun shadcn add @easeui/morphing-search
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/morphing-search.tsx "use client"; // easeui.dev/components/motion/morphing-search import { Search, X } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useRef, useState } from "react"; import { SPRING_PANEL } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface MorphingSearchProps { placeholder?: string; onSearch?: (query: string) => void; className?: string; } /** * A circular search button that morphs into a text field: the same element * grows and reshapes via a layout animation, rather than a new one popping * in beside it. Closes on Escape, on submit, or on an outside click. */ export function MorphingSearch({ placeholder = "Search...", onSearch, className }: MorphingSearchProps) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const inputRef = useRef<HTMLInputElement>(null); const rootRef = useRef<HTMLDivElement>(null); useEffect(() => { if (open) inputRef.current?.focus(); }, [open]); useEffect(() => { if (!open) return; const onPointerDown = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; window.addEventListener("pointerdown", onPointerDown); window.addEventListener("keydown", onKeyDown); return () => { window.removeEventListener("pointerdown", onPointerDown); window.removeEventListener("keydown", onKeyDown); }; }, [open]); const close = () => { setOpen(false); setQuery(""); }; return ( <div ref={rootRef} className={cn("inline-flex", className)}> <motion.div layout transition={SPRING_PANEL} style={{ borderRadius: 9999 }} className="flex h-11 items-center overflow-hidden bg-card shadow-[0_0_0_1px_var(--border)]" > <AnimatePresence mode="popLayout" initial={false}> {open ? ( <motion.form key="form" layout initial={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: 0.1, duration: 0.15 } }} exit={{ opacity: 0, transition: { duration: 0.1 } }} onSubmit={(event) => { event.preventDefault(); onSearch?.(query); }} className="flex items-center gap-1 pl-4 pr-1.5" > <Search aria-hidden="true" className="h-4 w-4 shrink-0 text-muted-foreground" /> <input ref={inputRef} value={query} onChange={(event) => setQuery(event.target.value)} placeholder={placeholder} className="h-11 w-56 min-w-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" /> <button type="button" aria-label="Close search" onClick={close} className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none after:absolute after:-inset-1 transition-colors duration-150 hover:bg-muted hover:text-foreground" > <X className="h-4 w-4" /> </button> </motion.form> ) : ( <motion.button key="trigger" layout type="button" aria-label="Search" onClick={() => setOpen(true)} // `layout` keeps the icon from stretching as the shell resizes // around it; the delay also hides it until the shell has // mostly finished shrinking, so it never appears mid-squash. initial={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: 0.18, duration: 0.1 } }} exit={{ opacity: 0, transition: { duration: 0 } }} className="flex h-11 w-11 shrink-0 items-center justify-center text-muted-foreground transition-colors duration-150 hover:text-foreground" > <Search className="h-[18px] w-[18px]" /> </motion.button> )} </AnimatePresence> </motion.div> </div> ); }lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; /** CSS string form of EASE_OUT for inline style transitions. */ export const EASE_OUT_CSS = "cubic-bezier(0.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| placeholder? | string | Search... | - |
| onSearch? | ((query: string) => void) | - | - |
| className? | string | - | - |
Updated