Expanding search
Free · MITSearch icon that springs open into a field with a recent-searches dropdown and a / shortcut; Esc or blur folds it back when empty.
Installation
$ npx shadcn@latest add https://goodui.dev/r/search-expand.jsonThe file lands in components/goodui/search-expand.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/search-expand works too.
Usage
import * as React from "react"
import { useRouter } from "next/navigation"
import { SearchExpand } from "@/components/goodui/search-expand"
export function ClientSearch() {
const router = useRouter()
const [recent, setRecent] = React.useState(["Maya Chen", "Invoice 1042", "Friday deep clean"])
return (
<header className="flex items-center justify-between border-b px-4 py-2">
<span className="font-semibold">Clients</span>
<SearchExpand
align="end"
placeholder="Search clients…"
recent={recent}
onSubmit={(q) => {
setRecent((r) => [q, ...r.filter((x) => x !== q)].slice(0, 6))
router.push("/clients?q=" + encodeURIComponent(q))
}}
onClearRecent={() => setRecent([])}
/>
</header>
)
}When to use
- App headers and toolbars with little room, where search is used often but should not take space when idle.
- Searching one list or table (clients, invoices, bookings) where recent queries are worth repeating.
- When users expect a / shortcut to jump to search, as on GitHub or YouTube.
- Searching across pages and running commands: use command-palette-morph.
- A search field that should always be visible, such as on a search results page: use input.
- Picking tags or several values from suggestions: use tag-input.
Examples
Live filtering, no submit
const [query, setQuery] = React.useState("")
const rows = invoices.filter((i) => i.client.toLowerCase().includes(query.toLowerCase()))
<SearchExpand value={query} onValueChange={setQuery} placeholder="Filter invoices…" shortcut={null} />Translated labels
<SearchExpand
placeholder="Kunden suchen…"
labels={{ search: "Suche", open: "Suche öffnen", clear: "Suche löschen", recent: "Letzte Suchen", matching: "Passende Suchen", clearRecent: "Löschen" }}
/>API reference
| Prop | Type | Default |
|---|---|---|
| value Controlled query text; pair with onValueChange. | string | — |
| defaultValue Initial query when uncontrolled. | string | "" |
| onValueChange Called on every keystroke, on clear and when a recent search fills the field. | (value: string) => void | — |
| onSubmit Called with the trimmed query on Enter (never with an empty string). | (query: string) => void | — |
| open Controlled open state; pair with onOpenChange. | boolean | — |
| defaultOpen Start expanded when uncontrolled. | boolean | false |
| onOpenChange Called when the field expands or folds back. | (open: boolean) => void | — |
| recent Shown in a dropdown while the field is focused; filtered by what is typed. | string[] | [] |
| onRecentSelect Defaults to filling the field and submitting. | (query: string) => void | — |
| onClearRecent Shows a "Clear" button in the dropdown header when set. | () => void | — |
| placeholder Placeholder text; also used as the input's aria-label. | string | "Search…" |
| expandedWidth Width in px when open (it never exceeds the parent). | number | 280 |
| shortcut Global key that opens the search; `null` to disable. | string | null | "/" |
| align Which edge the dropdown aligns to; use "end" when the search sits on the right. | "start" | "end" | "start" |
| labels Text for built-in controls and screen readers; pass translations here. | Partial<SearchExpandLabels> | — |
Also accepts every <div> attribute.
Accessibility
| / | Opens the search and focuses the field from anywhere on the page, unless focus is in an input, textarea, select or editable element. Change it with shortcut, or pass null to turn it off. |
| Enter / Space (on the icon) | Opens the search and focuses the field (native <button>). |
| Arrow Down / Arrow Up | Moves the highlight through recent searches, wrapping at both ends. |
| Enter | Runs the highlighted recent search, or submits the typed query. |
| Esc | First removes the highlight, then clears the text, then folds the search and returns focus to the icon. |
- The wrapper has role="search". The icon is a <button> with aria-expanded and an aria-label ("Open search" / "Search").
- The field is an <input type="search" role="combobox"> with aria-expanded, aria-controls and aria-activedescendant pointing at the highlighted recent search, so focus stays in the field.
- Recent searches are a role="listbox" of role="option" items with aria-selected.
- While folded, the field is aria-hidden and out of the tab order; only the icon is focusable.
- The field's accessible name is the placeholder, so write a placeholder that describes the search. All built-in strings can be translated through labels.
Motion
- The bar springs from 36px to expandedWidth (stiffness 420, damping 34) and the icon scales to 0.9.
- The recent-searches dropdown drops in from 6px above at 0.97 scale on a spring (stiffness 600, damping 38) and leaves in 120 ms.
- A highlight slides between recent searches with a shared layout animation; the clear button and / hint fade in.
- Reduced motion: the width changes without a spring, the dropdown and the clear button only fade, and the highlight jumps.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- onSubmit gets the trimmed query and never fires for an empty field; it does not close the search.
- recent is only data: the component never stores searches. Add the query to your own list in onSubmit.
- Picking a recent search fills the field and calls onSubmit; pass onRecentSelect to do something else instead.
- Use align="end" when the search sits at the end edge (right in LTR), so the dropdown does not overflow the screen. Alignment follows dir="rtl".
- Blur folds it back only when the field is empty; a typed query stays open.
FAQ
How many recent searches does it show?
Up to six, filtered by what is typed. A recent search that exactly matches the typed text is hidden.
Can I control whether it is open?
Yes. Pass open and onOpenChange; otherwise it manages its own state, starting from defaultOpen.
Related
A search field morphs into a command palette through a shared layout animation, with fuzzy matching, highlighted letters, grouped results, a sliding selection, arrow keys, Enter, Esc and Ctrl/Cmd+K.
Six field styles with top, floating, inline or hidden labels, focus ring, glow, underline-grow or lift, error shake with sliding messages, icon and text addons, clear button, counter and auto-growing textarea.
Chips input where Enter, comma or paste adds tags that spring in, Backspace arms then removes, duplicates shake, with a filtered suggestions dropdown, validation and a max count.
Search field with rotating, blurred-in placeholder hints; on submit the typed text dissolves into drifting particles drawn on a canvas.