Search Input Expand
A search icon opens into a full field while the controls beside it give up the space.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Search Input Expand
*
* A search icon in a toolbar opens into a full field. The field takes the
* width it needs and the controls beside it give way — fading and sliding
* out of the space the field is claiming.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are mixed
* from the inherited text color, so the toolbar reads correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `placeholder`, `title`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SearchInputExpandProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Toolbar heading kept to the left of the controls. */
title?: string;
/** Placeholder shown once the field is open. */
placeholder?: string;
/** Labels of the controls the field displaces. */
actions?: readonly string[];
/** Focus color for the open field. */
accent?: string;
/** Overall toolbar width. */
width?: number | string;
/** Fires on every keystroke in the open field. */
onQueryChange?: (query: string) => void;
};
type VariantConfig = {
/** Width of the open field, in px. */
openWidth: number;
/** Seconds for the width change. */
open: number;
/** Seconds for the displaced controls to clear out. */
clear: number;
/** Entrance spring for the clear button. */
chip: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: width is the one property here worth animating outright —
// the motion genuinely is a size change — so it runs as a short eased
// tween rather than a spring, which would overshoot and let the field
// wobble past the toolbar edge. The displaced controls leave faster than
// the field grows, so nothing is ever clipped mid-fade. The only spring
// belongs to an icon and sits above a 0.8 damping ratio.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a beat. For a dense app toolbar where search is used constantly.
subtle: {
openWidth: 214,
open: 0.22,
clear: 0.1,
chip: { type: "spring", stiffness: 560, damping: 46 },
},
// Enough time to see the controls step aside. The all-purpose setting.
default: {
openWidth: 232,
open: 0.28,
clear: 0.14,
chip: { type: "spring", stiffness: 480, damping: 40 },
},
// A longer, more deliberate opening for a marketing header or a page
// with a single prominent search.
playful: {
openWidth: 244,
open: 0.34,
clear: 0.16,
chip: { type: "spring", stiffness: 420, damping: 35 },
},
};
/** Square edge of the resting icon button — also the collapsed field width. */
const COLLAPSED = 36;
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
function SearchGlyph() {
return (
<svg
width="15"
height="15"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
aria-hidden
>
<circle cx="9" cy="9" r="5.4" />
<path d="m13.2 13.2 3.6 3.6" />
</svg>
);
}
export default function SearchInputExpand({
variant = "default",
title = "Documents",
placeholder = "Search documents",
actions = ["Filter", "Sort"],
accent = "#5B5BD6",
width = 320,
onQueryChange,
}: SearchInputExpandProps) {
const [open, setOpen] = useState(false);
const [focused, setFocused] = useState(false);
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Opening a field the user cannot type into is a dead end: the caret
// follows the motion, and closing hands focus back to the icon it came
// from so the tab position is never lost.
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
const close = (returnFocus: boolean) => {
setOpen(false);
setQuery("");
onQueryChange?.("");
if (returnFocus) requestAnimationFrame(() => toggleRef.current?.focus());
};
const widthTween = reduceMotion
? { duration: 0 }
: { duration: cfg.open, ease: [0.32, 0.72, 0, 1] as const };
const iconButtonStyle = {
display: "grid",
placeItems: "center",
width: 26,
height: 26,
flex: "0 0 auto",
padding: 0,
borderRadius: 8,
border: 0,
background: tone(10),
color: "inherit",
cursor: "pointer",
} as const;
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
width,
height: 44,
boxSizing: "border-box",
padding: "0 4px",
color: "inherit",
}}
>
{/* The displaced side of the toolbar. `overflow: hidden` lets the
flex box give up its width to the growing field, and the fade
finishes first so nothing is ever caught half-clipped. */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flex: "1 1 auto",
minWidth: 0,
overflow: "hidden",
}}
>
<span
style={{
fontSize: 13.5,
fontWeight: 650,
whiteSpace: "nowrap",
flex: "0 0 auto",
}}
>
{title}
</span>
<motion.div
animate={{ opacity: open ? 0 : 1, x: open ? -10 : 0 }}
transition={{
duration: reduceMotion ? 0 : cfg.clear,
ease: "easeOut",
}}
style={{
display: "flex",
gap: 6,
flex: "0 0 auto",
pointerEvents: open ? "none" : "auto",
}}
>
{actions.map((action) => (
<button
key={action}
type="button"
tabIndex={open ? -1 : 0}
aria-hidden={open || undefined}
style={{
padding: "5px 10px",
borderRadius: 8,
border: `1px solid ${tone(12)}`,
background: tone(5),
color: "inherit",
fontFamily: "inherit",
fontSize: 12,
fontWeight: 550,
whiteSpace: "nowrap",
cursor: "pointer",
}}
>
{action}
</button>
))}
</motion.div>
</div>
<motion.div
role="search"
animate={{ width: open ? cfg.openWidth : COLLAPSED }}
transition={widthTween}
style={{
display: "flex",
alignItems: "center",
flex: "0 0 auto",
height: 36,
boxSizing: "border-box",
borderRadius: 10,
border: `1px solid ${open ? tone(16) : tone(12)}`,
background: tone(open ? 8 : 5),
// Focus is painted on the shell rather than left to the default
// ring, which would sit inside the field and get clipped as the
// box grows. Color settles on a CSS transition so the animation
// loop stays width-and-transform only.
boxShadow: focused
? `0 0 0 3px color-mix(in srgb, ${accent} 26%, transparent)`
: "0 0 0 0 transparent",
outlineColor: accent,
transition:
"border-color 160ms ease-out, background-color 160ms ease-out, box-shadow 160ms ease-out",
overflow: "hidden",
}}
>
{open ? (
<>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: COLLAPSED,
height: 34,
flex: "0 0 auto",
opacity: 0.55,
}}
>
<SearchGlyph />
</span>
<motion.input
ref={inputRef}
type="search"
value={query}
aria-label={placeholder}
placeholder={placeholder}
onChange={(event) => {
setQuery(event.target.value);
onQueryChange?.(event.target.value);
}}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false);
if (query.length === 0) setOpen(false);
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
close(true);
}
}}
// The field's own contents arrive after the box has room for
// them, so the text never appears squeezed into a shell that
// is still growing.
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: reduceMotion ? 0 : cfg.clear,
ease: "easeOut",
delay: reduceMotion ? 0 : cfg.open * 0.45,
}}
style={{
flex: "1 1 auto",
minWidth: 0,
height: 34,
padding: 0,
border: "none",
background: "transparent",
color: "inherit",
fontFamily: "inherit",
fontSize: 13.5,
outline: "none",
}}
/>
<AnimatePresence initial={false}>
{query.length > 0 && (
<motion.button
key="clear"
type="button"
aria-label="Clear search"
onClick={() => {
setQuery("");
onQueryChange?.("");
inputRef.current?.focus();
}}
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.8 }
}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={reduceMotion ? { duration: 0.1 } : cfg.chip}
style={{ ...iconButtonStyle, marginRight: 5 }}
>
<svg
width="11"
height="11"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
aria-hidden
>
<path d="M5 5l10 10M15 5 5 15" />
</svg>
</motion.button>
)}
</AnimatePresence>
</>
) : (
<button
ref={toggleRef}
type="button"
aria-label={placeholder}
aria-expanded={false}
onClick={() => setOpen(true)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{
display: "grid",
placeItems: "center",
width: COLLAPSED,
height: 34,
flex: "0 0 auto",
padding: 0,
border: 0,
background: "transparent",
color: "inherit",
cursor: "pointer",
outline: "none",
}}
>
<SearchGlyph />
</button>
)}
</motion.div>
</div>
);
}About this pattern
Toolbars run out of room long before the features do, so search hides behind its icon until it is wanted. Clicking the icon widens the shell to a typing field on a short eased curve — width is one of the few properties worth animating outright, and a spring here would let the box overshoot the toolbar edge. The neighbouring buttons clear out faster than the field grows, which is what makes the exchange read as one control taking the room rather than two things fighting over it. The caret follows the opening, Escape closes the field, and focus lands back on the icon it came from.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Search results
A toolbar field that stays an icon until it is needed, then claims the row.
Related patterns
- Inline Edit SwapA value becomes a field where it sits: the surface arrives around the words and the row never changes size.
- Select Dropdown OpenThe list unfolds under the field, options a beat behind the panel, current choice already marked.
- Combobox Filter NarrowTyping fades out the options that no longer match while the survivors slide up.