# Search Input

> A React search input with debounced search, a focus shortcut, a spinner, a results count, recent searches and suggestions.

- Type: React component (Inputs)
- Access: Free (the source is open to everyone)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add search-input`
- Docs: https://wingo-ui.com/components/search-input

## Interaction

Press / anywhere on the page (not while typing in another field) and the search field takes focus with its text selected; the / hint at the end of the empty box shows which key it is and hides while the field is focused. Type and the search runs once you pause (300 ms by default): the magnifier crossfades into a spinner while the page loads, and a muted count like 318 results appears at the end and is read out politely. Enter searches at once, saves the query to the recent searches and closes the dropdown. On focus a dropdown opens under the field with Recent searches (each with an × to forget it and a Clear button for all of them) and suggestions, both filtered by what you type with the matches in bold. Arrow Down moves into it while the caret stays in the field, Enter picks the highlighted row, Delete or Shift+Backspace removes a highlighted recent search, and Escape first closes the dropdown, then clears the text, then leaves the field. The × in the box clears and searches at once. With expandable the field starts as a square icon button, grows with a spring on click, tap or the shortcut and folds back when you leave it empty. On a phone the keyboard shows a Search key, the text is 16px so iOS never zooms, the dropdown stays under the field above the keyboard with 44px rows, no key hint is shown, and picking a search or pressing Search lowers the keyboard so the results show.

## Installation

```bash
npx wingo-ui@latest add search-input
```

The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add https://wingo-ui.com/r/search-input.json`

Files:

- `components/ui/search-input.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Kbd](https://wingo-ui.com/components/kbd) ([Markdown](https://wingo-ui.com/components/kbd.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useDebounce](https://wingo-ui.com/components/use-debounce) ([Markdown](https://wingo-ui.com/components/use-debounce.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { Building2, Car, Laptop } from "lucide-react"
import { SearchInput } from "@/components/ui/search-input"

export function ListingsSearch() {
  const [loading, setLoading] = useState(false)
  const [total, setTotal] = useState<number>()

  return (
    <SearchInput
      placeholder="Search listings"
      recentStorageKey="recent-listings"
      suggestions={[
        {
          label: "Categories",
          items: [
            { value: "real-estate", label: "Real estate", icon: <Building2 /> },
            { value: "cars", label: "Cars", icon: <Car /> },
            { value: "electronics", label: "Electronics", icon: <Laptop /> },
          ],
        },
      ]}
      loading={loading}
      resultCount={total}
      resultLabel={(n) => (n === 1 ? "1 listing" : `${n} listings`)}
      onSearch={async (query) => {
        if (!query) return setTotal(undefined)
        setLoading(true)
        const response = await fetch(`/api/listings?q=${encodeURIComponent(query)}`)
        const { total } = (await response.json()) as { total: number }
        setTotal(total)
        setLoading(false)
      }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label?` | `ReactNode` | `""` | Renders the field anatomy around the box: the label above it, linked with htmlFor. Without it the input is named by aria-label, else the placeholder. |
| `description?` | `ReactNode` | `""` | Helper text under the box, linked with aria-describedby. |
| `error?` | `ReactNode` | `""` | Replaces the description with the animated error and marks the field invalid. |
| `required?` | `boolean` | `false` | The red mark after the label and the native required attribute. |
| `invalid?` | `boolean` | `false` | The invalid look and aria-invalid without a message. |
| `disabled?` | `boolean` | `false` | Dims the field, blocks input and unbinds the shortcut. |
| `readOnly?` | `boolean` | `false` | The query stays visible and selectable but cannot change; no clear button and no dropdown. |
| `placeholder?` | `string` | `"Search"` | Hint text while the field is empty; also the accessible name when there is no label or aria-label. The demo passes Search listings. |
| `clearLabel?` | `string` | `"Clear"` | The clear button's accessible name and hover title. |
| `loadingLabel?` | `string` | `"Searching"` | What screen readers hear for the spinner. |
| `defaultValue?` | `string` | `""` | The starting query while uncontrolled. A form reset returns to it; pass value and onValueChange to control it instead. |
| `name?` | `string` | `""` | The form field name on the native input, so a GET form submits ?q=… like a plain search box. |
| `labels?` | `Partial<{ recent: string; suggestions: string; clearRecent: string; removeRecent: string }>` |  | The dropdown copy, merged over { recent: "Recent searches", suggestions: "Suggestions", clearRecent: "Clear", removeRecent: "Remove" }. suggestions heads loose suggestion options and names the list for screen readers. |
| `resultLabel?` | `(count: number) => string` | `"defaultResultLabel"` | Formats the count shown and announced; the default gives "1 result" / "318 results". The demo writes Romanian plurals (318 rezultate, 20 de rezultate). |
| `icon?` | `ReactNode` |  | Replaces the leading magnifier; it still crossfades with the spinner while loading. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Height 32 / 40 / 48px with 13 / 14 / 15px text (16px on phones); md grows to 44px on touch. The key hint and the count scale along. |
| `variant?` | `"soft" \| "outline" \| "underline" \| "ghost"` | `"soft"` | The box look, shared with every input: a filled well, a bordered box, a bottom line, or no fill until hover (toolbars). |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Corner shape scaled with the size like the button's; full makes the classic pill search bar. |
| `accent?` | `string` | `""` | Any CSS color for the focus ring, the outline and underline focus color and the caret. Empty uses the ring token. |
| `loading?` | `boolean` | `false` | The magnifier crossfades into a spinner and the input gets aria-busy; typing stays possible. Set it while your request runs. |
| `resultCount?` | `number` | `0` | Shown muted at the end of the box (dimmed while loading) and announced in a polite live region once it settles. Unset by default, which hides it; 0 shows "0 results". In the playground the slider pins a count, and Live count feeds the demo's real result count. |
| `value?` | `string` |  | The query, controlled; pair it with onValueChange. |
| `onValueChange?` | `(value: string) => void` |  | Fires on every keystroke, a clear and a picked suggestion. |
| `onSearch?` | `(query: string) => void` |  | Fires with the trimmed query after the debounce, at once on Enter and on a picked row, and with "" at once on clear. The same query twice in a row only fires again on Enter. |
| `debounce?` | `number` | `300` | Milliseconds of quiet typing before onSearch runs. 0 searches on every keystroke. |
| `minLength?` | `number` | `0` | Shorter queries do not call onSearch (the empty query after a clear always does), so a single letter never hits the server. |
| `clearable?` | `boolean` | `true` | An × pops in while there is text; it clears, searches at once and keeps focus in the field. Escape clears either way. |
| `shortcut?` | `string` | `"/"` | Focuses the field and selects its text from anywhere, in mod+k notation ("/", "mod+k", "shift+mod+f"). Keys without a modifier are ignored while you type in another field. Empty binds nothing. mod+k usually belongs to a command menu; give each search on a page its own key. |
| `showShortcut?` | `boolean` | `true` | The key hint at the end of the box while it is empty and not focused (⌘K on Apple devices, Ctrl+K elsewhere). Never shown on touch screens. |
| `selectOnFocus?` | `boolean` | `false` | Selects the whole query when the field takes focus by click, tap or Tab, so typing replaces it. The shortcut always selects it. |
| `maxLength?` | `number` | `0` | The native character limit for the query. 0 means no limit. |
| `dropdown?` | `boolean` | `true` | Shows recent searches and suggestions under the field while it is focused (in the page flow, not a sheet, so it stays above a phone keyboard). With it the input is a combobox driven by arrow keys; without it a plain searchbox. |
| `recentStorageKey?` | `string` | `""` | Keeps recent searches in localStorage under this key, read after mount. Blocked storage (private mode) falls back to memory. Empty keeps them in memory for the page's life. The demo uses its own key with three seeded searches. |
| `maxRecent?` | `number` | `5` | How many recent searches are kept; a repeat moves to the top instead of doubling (compared without case or diacritics). |
| `recent?` | `string[]` |  | Controlled recent searches, newest first; pair it with onRecentChange (then storage is left to you). |
| `onRecentChange?` | `(recent: string[]) => void` |  | Fires when a search is saved, removed, cleared, or restored from storage after mount. |
| `suggestions?` | `ListboxItems (SearchSuggestion \| SearchSuggestionGroup)[]` |  | Static quick picks (categories, popular searches) under the recents, filtered by the query with matches in bold. Loose options go under labels.suggestions; groups keep their label. Options take icon, description, badge (a count) and keywords. |
| `onSuggestionSelect?` | `(option: SearchSuggestion) => void` |  | Called with a picked suggestion (not a recent search). Without it the option's text fills the field, searches and is saved as a recent search. |
| `expandable?` | `boolean` | `false` | Starts as a square icon button the size of the box; a click, tap or the shortcut grows it to expandedWidth with a spring (the magnifier glides into place and the text fades in), and it folds back when it loses focus while empty. |
| `expandedWidth?` | `number` | `280` | Width in px once expanded, capped by the container. Only with expandable. |
| `onChange?` | `(event: ChangeEvent<HTMLInputElement>) => void` |  | The native change event, untouched; a clear fires it too, like a keystroke. |
| `onClear?` | `() => void` |  | Called after the × or Escape emptied the field (onSearch has already run with ""). Focus stays in the input. |
| `prefix?` | `ReactNode` |  | Text or an element before the query, after the magnifier: a scope like "in Cars:" or a small select. Passed to Input. |
| `rightIcon?` | `ReactNode` |  | A trailing icon after the clear button, e.g. a filter or microphone glyph. Passed to Input. |
| `clearIcon?` | `ReactNode` |  | Replaces the clear button's icon (a 16px X). |
| `fieldProps?` | `Partial<FieldProps>` |  | Anything else for the Field wrapper: optional, labelAction, orientation, labelWidth, hideLabel. |
| `id?` | `string` |  | The native input's id. Defaults to useId(); inside a <Field> the Field's id wins. |
| `ref?` | `RefObject<HTMLInputElement \| null> or (node) => void` |  | The native input. Every other native prop lands there too: autoFocus, onBlur, onKeyDown (runs first; preventDefault skips the built-in keys), aria-*, data-*; onClear runs after a clear. |
| `classNames?` | `Partial<Record<InputSlot \| "root" \| "icon" \| "shortcut" \| "count" \| "dropdown" \| "list" \| "option" \| "group" \| "recentRemove" \| "clearRecent" \| "expand", string>>` |  | Extra classes for the inner parts: the Input parts (box, input, clear…), the icon stack, the key hint, the count, the dropdown and its list, rows and group labels, the recent × and Clear buttons, the expand button. |
| `className?` | `string` |  | Classes for the outermost element: the Field root when there is a label, description or error, else the search root. |

## Examples

- **Shortcut hints**: mod+k and shift+mod+f focus their field from anywhere; the hint shows ⌘K on a Mac and Ctrl+K elsewhere, and hides on focus and on touch.
- **Recent searches**: Controlled recents: remove one with the ×, or highlight it with ↓ and press Delete; Clear history forgets them all.
- **Suggestions**: A Categories group with icons and counts under the recents, filtered as you type; onSuggestionSelect receives the option.
- **Expandable in a toolbar**: A phone-width header: the search starts as a round icon button, grows to 240px on tap and folds back when you leave it empty.
- **Sizes and looks**: sm pill, outline md, lg outline pill and underline.
- **Loading and results count**: Type and pause: the magnifier turns into a spinner for the fake request, then the count updates (318 results, 1 result).

## See also

- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [Kbd](https://wingo-ui.com/components/kbd) ([Markdown](https://wingo-ui.com/components/kbd.md))
- [Command Menu](https://wingo-ui.com/components/command-menu) ([Markdown](https://wingo-ui.com/components/command-menu.md))
- [useDebounce](https://wingo-ui.com/components/use-debounce) ([Markdown](https://wingo-ui.com/components/use-debounce.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))

---

Canonical: https://wingo-ui.com/components/search-input
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
