Forms
LiquidSearch
A round search button that stretches into a search box with a wobbly, liquid spring. Esc or clicking away while empty folds it back.
Download
- React
- Tailwind CSS v4
- motion
components/ui/liquid-search.tsx
"use client";
import { useRef, useState, type FocusEvent, type FormEvent, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type LiquidSearchProps = {
/** Called with the trimmed text when the search is sent (Enter or the search button). */
onSearch?: (query: string) => void;
/** Hint text in the open box. */
placeholder?: string;
/** Name read out by screen readers. */
label?: string;
/** Width in px when open. Never wider than its container. */
width?: number;
/** Start open. */
defaultOpen?: boolean;
/** Glow colour when open. */
accent?: string;
/** Box look. */
className?: string;
};
function SearchIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="size-5">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
}
/* LiquidSearch: a round search button that stretches into a search box with a wobbly, liquid spring. Esc or clicking away (when empty) folds it back. */
export function LiquidSearch({
onSearch,
placeholder = "Search…",
label = "Search",
width = 320,
defaultOpen = false,
accent = "#a78bfa",
className = "border border-white/12 bg-[#141026] text-white",
}: LiquidSearchProps) {
const reduce = useReducedMotion();
const [open, setOpen] = useState(defaultOpen);
const [query, setQuery] = useState("");
const input = useRef<HTMLInputElement>(null);
const button = useRef<HTMLButtonElement>(null);
const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 360, damping: 22, mass: 0.9 };
const expand = () => {
setOpen(true);
requestAnimationFrame(() => input.current?.focus());
};
const collapse = (refocus: boolean) => {
setOpen(false);
setQuery("");
if (refocus) button.current?.focus();
};
const submit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!open) return expand();
const q = query.trim();
if (q) onSearch?.(q);
else input.current?.focus();
};
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key !== "Escape") return;
e.preventDefault();
if (query) setQuery("");
else collapse(true);
};
const onBlur = (e: FocusEvent<HTMLFormElement>) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null) && !query.trim()) collapse(false);
};
return (
<motion.form
role="search"
onSubmit={submit}
onBlur={onBlur}
initial={false}
animate={{
width: open ? width : 48,
scaleY: reduce ? 1 : open ? [1, 0.86, 1.04, 1] : [1, 0.9, 1],
boxShadow: open ? `0 0 0 1px ${accent}55, 0 8px 32px -8px ${accent}88` : "0 0 0 0px transparent, 0 0 0 0 transparent",
}}
transition={{ width: spring, scaleY: { duration: reduce ? 0 : 0.45 }, boxShadow: { duration: reduce ? 0 : 0.3 } }}
style={{ maxWidth: "100%" }}
className={`relative flex h-12 items-center overflow-hidden rounded-full ${className}`}
>
<button
ref={button}
type="submit"
aria-label={open ? label : `Open ${label.toLowerCase()}`}
aria-expanded={open}
className="grid size-12 shrink-0 place-items-center rounded-full text-white/70 outline-none transition-colors hover:text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-inset"
>
<SearchIcon />
</button>
<div inert={!open} className="flex min-w-0 flex-1 items-center pr-1.5">
<input
ref={input}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKey}
placeholder={placeholder}
aria-label={label}
enterKeyHint="search"
className="h-12 w-full min-w-0 bg-transparent text-base text-white outline-none placeholder:text-white/35 [&::-webkit-search-cancel-button]:appearance-none"
/>
<AnimatePresence initial={false}>
{open && query && (
<motion.button
key="clear"
type="button"
aria-label="Clear search"
onClick={() => {
setQuery("");
input.current?.focus();
}}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 28 }}
className="grid size-9 shrink-0 place-items-center rounded-full text-white/60 outline-none hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="size-4">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</motion.button>
)}
</AnimatePresence>
</div>
</motion.form>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/liquid-search.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/liquid-search-demo.tsx
"use client"; import { useState } from "react"; import { LiquidSearch } from "@/components/ui/liquid-search"; export default function LiquidSearchExample() { const [last, setLast] = useState(""); return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-5"> <div className="flex w-full max-w-xs flex-col items-center gap-4"> <LiquidSearch placeholder="Search components…" onSearch={setLast} /> <p aria-live="polite" className="h-5 text-sm text-white/50"> {last && `Searched for “${last}”`} </p> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| onSearch | (query: string) => void | — | Called with the trimmed text on Enter or the search button. |
| placeholder | string | "Search…" | Hint text in the open box. |
| label | string | "Search" | Name read out by screen readers. |
| width | number | 320 | Open width in px. Never wider than its container. |
| defaultOpen | boolean | false | Start open. |
| accent | string | "#a78bfa" | Glow colour when open. |
| className | string | dark pill | Box look. |