Expanding search

Free · MIT

Search icon that springs open into a field with a recent-searches dropdown and a / shortcut; Esc or blur folds it back when empty.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/search-expand.json

The 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

Use it for
  • 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.
Reach for something else
  • 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

PropTypeDefault
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.
booleanfalse
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).
number280
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 UpMoves the highlight through recent searches, wrapping at both ends.
EnterRuns the highlighted recent search, or submits the typed query.
EscFirst 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