Global Search React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React global search bar for an app header: grouped results from every entity as you type, scopes, recents and suggestions, full screen on phones.
Press / (or click the field) and it grows from its resting width on a smooth spring; with the query still empty the panel shows your recent searches and quick suggestions. Type and results arrive grouped (clients, contacts, offers, invoices) with the matched letters bold, a spinner in the field while the server answers and a “See all 24” row closing each long group. Arrow keys move, Enter opens the highlighted result or, with nothing highlighted, searches everywhere (the footer row does the same). Tab and Shift+Tab switch the scope chips while the panel is open; Escape closes the panel, a second Escape clears the text and a third leaves the field. Recents remember submitted queries and opened results; remove one with its × or Delete, or clear them all. On a phone the search is an icon (or a full-width field) that opens a full-screen search with the keyboard already up, 48px rows, sideways-scrolling scopes (with a keyboard, Tab reaches them and the arrow keys switch) and a Cancel button; Escape closes it.
Global Search is a Pro React component in the React navigation components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add global-search, together with UIProvider, Button, Field, Kbd, Listbox and 10 more and the npm packages @radix-ui/react-dialog, @radix-ui/react-popover, @radix-ui/react-visually-hidden, lucide-react and motion.
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 global-searchGlobal Search is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import GlobalSearch from @/components/ui/global-search.
"use client"
import { Building2, FileWarning, Receipt } from "lucide-react"
import { GlobalSearch, type SearchResultGroup } from "@/components/ui/global-search"
type Hit = { id: string; name: string; city: string }
export function HeaderSearch() {
return (
<GlobalSearch
placeholder="Search Acme CRM"
scopes={[
{ value: "all", label: "All" },
{ value: "clients", label: "Clients", icon: <Building2 /> },
{ value: "invoices", label: "Invoices", icon: <Receipt /> },
]}
loadResults={async (query, { signal, scope }): Promise<SearchResultGroup[]> => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&scope=${scope}`, { signal })
const data: { clients: Hit[]; total: number } = await res.json()
return [
{
id: "clients",
label: "Clients",
total: data.total,
href: `/clients?q=${encodeURIComponent(query)}`,
items: data.clients.map((c) => ({ value: c.id, label: c.name, description: c.city, href: `/clients/${c.id}` })),
},
]
}}
suggestions={[{ value: "overdue", label: "Overdue invoices", icon: <FileWarning />, href: "/invoices/overdue" }]}
onSubmit={(query) => console.log("search everywhere:", query)}
/>
)
}Props
resultsSearchResultGroup[]Static results filtered locally as you type (label, textValue, keywords and a text description; case and diacritics never matter). A group is { id?, label, items, total?, href? }; a result is a listbox option { value, label, description?, icon?, badge?, keywords? } plus href?, external? and meta? (a trailing amount or date).
loadResults(query: string, context: { signal: AbortSignal; scope: string }) => Promise<SearchResultGroup[]>Server search, used instead of results: debounced, the previous request aborted, the last 20 answers cached, the old results kept while the new ones load; a rejected promise shows an error with Try again. A group with href and total > items.length ends in a See all row.
suggestionsSearchResult[]Quick links under the recents while the query is empty (“Overdue invoices”, “New clients this month”).
scopesSearchScope[]Chips above the results ({ value, label, icon? }): loadResults gets the scope; static results narrow to the group whose id equals it (any other value, like “all”, keeps every group).
placeholderdefault"Search"stringThe field's placeholder and accessible name, also on the phone trigger.
labelsPartial<GlobalSearchLabels>Every string: title, recent, suggestions, seeAll(total, label), searchFor(query, scope?), cancel, clearRecent, removeRecent, noResults(query), results(count, formatted), scopeHint, scopes, searchIn(scope), navigate, open, close, clear, loading, error, retry, minQuery(min), idle. English defaults.
sizedefault"md""sm" | "md" | "lg"The field box: 32 / 40 / 48px (md is 44px on touch).
variantdefault"soft""soft" | "outline" | "ghost"The field look: a filled well, an outlined box, or bare until hovered.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner radius of the field, scaled with its size like the button's.
widthdefault320numberResting width of the desktop field in px (capped by its container).
expandedWidthdefault480numberWidth while focused or open, and the minimum width of the results panel. The field grows to it on a spring.
mobileTriggerdefault"icon""icon" | "field"Phones: a 44px search icon for a bar, or a full-width field-looking button; both open the full-screen search.
showShortcutdefaulttruebooleanThe key hint at the end of the field while it is empty and not focused (never on touch).
highlightMatchesdefaulttruebooleanMatched letters of string labels in bold.
defaultOpendefaultfalsebooleanStarts with the panel (desktop) or the full-screen search (phones) open. Use open and onOpenChange to control it.
openbooleanControlled open state of the panel or overlay; pair it with onOpenChange.
onOpenChange(open: boolean) => voidFires when the panel or overlay opens or closes.
valuestringControlled query; defaultValue (default "") and onValueChange complete the pair.
defaultValuedefault""stringThe query it starts with while uncontrolled.
onValueChange(value: string) => voidFires on every keystroke, and when a recent query is picked or the text is cleared.
scopestringControlled scope (a scope value); defaultScope (default: the first scope) and onScopeChange complete the pair.
defaultScopedefault""stringThe scope it starts in while uncontrolled; empty means the first scope. The demo's scopes are all (the first), clients, contacts, proposals and invoices.
onScopeChange(scope: string) => voidFires when a chip is picked or Tab cycles the scope.
disableddefaultfalsebooleanDims the field, ignores input and the shortcut.
onSubmit(query: string, scope: string) => voidEnter with nothing highlighted, or the “Search for … everywhere” row: go to your full results page.
onSelect(result: SearchResult) => voidA result or suggestion was picked. Default: navigate to result.href through the LinkProvider's router (external links in a new tab).
debouncedefault250numberMilliseconds of quiet typing before loadResults runs.
minQueryLengthdefault1numberCharacters needed before results are searched; shorter queries show a hint and the recents stay hidden.
shortcutdefault"/"stringFocuses the field from anywhere (opens the full-screen search on phones), never while typing in another field. "" binds nothing. The showcase binds “/” itself, so the demo uses S while this is “/”.
recentdefaulttruebooleanRemembers submitted queries and opened results, shown while the query is empty (a repeat moves to the top).
recentKeydefault"global-search:recent"stringLocal storage key for the recents (use-persisted-state), synced across tabs. "" keeps them in memory.
maxRecentdefault5numberHow many recents to keep.
defaultRecentstring[]Recent queries shown while nothing is stored yet (first visit, onboarding).
onRecentChange(recent: GlobalSearchRecent[]) => voidFires when a recent is added, removed or cleared, e.g. to sync them to the user's account.
responsivedefaulttruebooleanBelow 768px the field becomes the phone trigger and opens a full-screen search.
modedefault"auto""auto" | "popover" | "overlay"auto follows the screen (with responsive); popover keeps the field and its panel everywhere; overlay always uses the phone trigger and the full-screen search.
localedefault""stringFormats the result counts in the footer and the announcements (“1,240 results”). Empty: the LocaleProvider's, else the browser's.
containerHTMLElement | nullPortal target of the panel and the full-screen search. Default document.body.
classNamesPartial<Record<GlobalSearchSlot, string>>Classes per part: box, input, icon, shortcut, clear, content, scopes, scope, list, group, result, seeAll, recent, recentRemove, clearRecent, status, footer, searchFor, mobileTrigger, overlay, cancel.
Examples
Static results
Results filtered locally, without a server.
Scopes
Chips above the results; Tab and Shift+Tab switch them.
Phone
The full-screen search behind an icon button.
Phone field
mobileTrigger=“field” under a large title.
API error
A failed request with Try again.
Custom rows
Avatars, amounts and status badges; see all.
Sizes and variants
sm / md / lg in soft, outline and ghost.
Dependencies
Files
- components/ui/global-search.tsx
FAQ
Is the React Global Search component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Global Search?
Run npx wingo-ui@latest add global-search. It also adds the Wingo UI items it builds on (UIProvider, Button, Field, Kbd, Listbox and 10 more) and the npm packages @radix-ui/react-dialog, @radix-ui/react-popover, @radix-ui/react-visually-hidden, lucide-react and motion. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/global-search.
How does Global Search behave on phones?
Below 768px the field becomes the phone trigger and opens a full-screen search. The responsive prop controls this and is on by default.
See also
Used in
More React navigation components
Articles using this component
- Help Center Template for React: Search, Topics, Articles
Build it: screens and apps9 min read
- React Command Palette: Cmd+K, Nested Pages and Mobile
Component guides9 min read