Search Input React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React search input with debounced search, a focus shortcut, a spinner, a results count, recent searches and suggestions.
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.
Search Input is a free React component in the React input components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add search-input, together with Field, Input, Kbd, Listbox, Spinner and 7 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add search-inputUsage
Import SearchInput from @/components/ui/search-input.
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
labeldefault""ReactNodeRenders 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.
descriptiondefault""ReactNodeHelper text under the box, linked with aria-describedby.
errordefault""ReactNodeReplaces the description with the animated error and marks the field invalid.
requireddefaultfalsebooleanThe red mark after the label and the native required attribute.
invaliddefaultfalsebooleanThe invalid look and aria-invalid without a message.
disableddefaultfalsebooleanDims the field, blocks input and unbinds the shortcut.
readOnlydefaultfalsebooleanThe query stays visible and selectable but cannot change; no clear button and no dropdown.
placeholderdefault"Search"stringHint text while the field is empty; also the accessible name when there is no label or aria-label. The demo passes Search listings.
clearLabeldefault"Clear"stringThe clear button's accessible name and hover title.
loadingLabeldefault"Searching"stringWhat screen readers hear for the spinner.
defaultValuedefault""stringThe starting query while uncontrolled. A form reset returns to it; pass value and onValueChange to control it instead.
namedefault""stringThe form field name on the native input, so a GET form submits ?q=… like a plain search box.
labelsPartial<{ 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.
resultLabeldefault"defaultResultLabel"(count: number) => stringFormats the count shown and announced; the default gives "1 result" / "318 results". The demo writes Romanian plurals (318 rezultate, 20 de rezultate).
iconReactNodeReplaces the leading magnifier; it still crossfades with the spinner while loading.
sizedefault"md""sm" | "md" | "lg"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.
variantdefault"soft""soft" | "outline" | "underline" | "ghost"The box look, shared with every input: a filled well, a bordered box, a bottom line, or no fill until hover (toolbars).
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner shape scaled with the size like the button's; full makes the classic pill search bar.
accentdefault""stringAny CSS color for the focus ring, the outline and underline focus color and the caret. Empty uses the ring token.
loadingdefaultfalsebooleanThe magnifier crossfades into a spinner and the input gets aria-busy; typing stays possible. Set it while your request runs.
resultCountdefault0numberShown 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.
valuestringThe query, controlled; pair it with onValueChange.
onValueChange(value: string) => voidFires on every keystroke, a clear and a picked suggestion.
onSearch(query: string) => voidFires 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.
debouncedefault300numberMilliseconds of quiet typing before onSearch runs. 0 searches on every keystroke.
minLengthdefault0numberShorter queries do not call onSearch (the empty query after a clear always does), so a single letter never hits the server.
clearabledefaulttruebooleanAn × pops in while there is text; it clears, searches at once and keeps focus in the field. Escape clears either way.
shortcutdefault"/"stringFocuses 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.
showShortcutdefaulttruebooleanThe 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.
selectOnFocusdefaultfalsebooleanSelects the whole query when the field takes focus by click, tap or Tab, so typing replaces it. The shortcut always selects it.
maxLengthdefault0numberThe native character limit for the query. 0 means no limit.
dropdowndefaulttruebooleanShows 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.
recentStorageKeydefault""stringKeeps 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.
maxRecentdefault5numberHow many recent searches are kept; a repeat moves to the top instead of doubling (compared without case or diacritics).
recentstring[]Controlled recent searches, newest first; pair it with onRecentChange (then storage is left to you).
onRecentChange(recent: string[]) => voidFires when a search is saved, removed, cleared, or restored from storage after mount.
suggestionsListboxItems (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) => voidCalled 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.
expandabledefaultfalsebooleanStarts 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.
expandedWidthdefault280numberWidth in px once expanded, capped by the container. Only with expandable.
onChange(event: ChangeEvent<HTMLInputElement>) => voidThe native change event, untouched; a clear fires it too, like a keystroke.
onClear() => voidCalled after the × or Escape emptied the field (onSearch has already run with ""). Focus stays in the input.
prefixReactNodeText or an element before the query, after the magnifier: a scope like "in Cars:" or a small select. Passed to Input.
rightIconReactNodeA trailing icon after the clear button, e.g. a filter or microphone glyph. Passed to Input.
clearIconReactNodeReplaces the clear button's icon (a 16px X).
fieldPropsPartial<FieldProps>Anything else for the Field wrapper: optional, labelAction, orientation, labelWidth, hideLabel.
idstringThe native input's id. Defaults to useId(); inside a <Field> the Field's id wins.
refRefObject<HTMLInputElement | null> or (node) => voidThe 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.
classNamesPartial<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.
classNamestringClasses 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).
Dependencies
Files
- components/ui/search-input.tsx
FAQ
Is the React Search Input component free?
Yes. The source is free and needs no account: copy it from the Code tab or install it with the CLI.
How do I install Search Input?
Run npx wingo-ui@latest add search-input. It also adds the Wingo UI items it builds on (Field, Input, Kbd, Listbox, Spinner and 7 more) and the npm packages motion and lucide-react. With shadcn, run npx shadcn@latest add https://wingo-ui.com/r/search-input.json.
How does Search Input behave on phones?
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.

