Studio KitDeveloper-Interface Animationsmotion/react

ExpandableSearch

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 props
260

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add expandable-search

Writes 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.

example.tsx
1import { ExpandableSearch } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ExpandableSearch />6  );7}

Props

PropTypeDefault
width

Expanded width in pixels.

number260
placeholder

Input placeholder text.

string'Search components…'
onSearch

Called with the current query when Enter is pressed.

(query: string) => voidundefined
defaultOpen

Start in the expanded state.

booleanfalse
className

Additional class merged onto the root element.

string''

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

components/ui/expandable-search.tsx
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}