Turns an empty search into fixes that are proven to return results: a spelling fix, a layout fix, a dropped filter or all categories.
StopsUsers quit on empty search results
"use client"
import * as React from "react"
import { Search } from "lucide-react"
import { ZeroResultRescue, type SearchAttempt } from "@/components/ui/zero-result-rescue"
const products = [
{
name: "Sony WH-1000XM5 wireless headphones",
category: "Audio",
price: 29990,
},
{
name: "boAt Rockerz 450 wireless headphones",
category: "Audio",
price: 1499,
},
{
name: "JBL Tune 510BT wireless headphones",
category: "Audio",
price: 2999,
},
{ name: "Noise Buds wireless earbuds", category: "Audio", price: 1299 },
{ name: "boAt Bassheads wired earphones", category: "Audio", price: 399 },
{
name: "Zebronics Thunder wireless headphones",
category: "Gaming",
price: 899,
},
{ name: "Logitech M235 wireless mouse", category: "Computers", price: 699 },
{
name: "Portronics Key2 wireless keyboard",
category: "Computers",
price: 899,
},
]
const categories = ["Audio", "Gaming", "Computers"]
const budget = { id: "under-1000", label: "Under ₹1,000" }
const vocabulary = [...new Set(products.flatMap((p) => `${p.name} ${p.category}`.toLowerCase().split(/[^\p{L}\p{N}]+/u)))]
function search(attempt: SearchAttempt, category: string) {
const words = attempt.query.toLowerCase().split(/\s+/).filter(Boolean)
return products.filter(
(p) =>
(attempt.allCategories || p.category === category) &&
(!attempt.filters.includes(budget.id) || p.price < 1000) &&
words.every((word) => `${p.name} ${p.category}`.toLowerCase().includes(word)),
)
}
export function Demo() {
const [query, setQuery] = React.useState("wireles hedphones")
const [category, setCategory] = React.useState("Audio")
const [allCategories, setAllCategories] = React.useState(false)
const [budgetOn, setBudgetOn] = React.useState(true)
const filters = React.useMemo(() => (budgetOn ? [budget] : []), [budgetOn])
// Counted like a count-only API call would be: async, a quarter second each, all at once.
const count = React.useCallback(
(attempt: SearchAttempt, signal: AbortSignal) =>
new Promise<number>((resolve, reject) => {
const timer = setTimeout(() => resolve(search({ ...attempt, allCategories: allCategories || attempt.allCategories }, category).length), 250)
signal.addEventListener("abort", () => {
clearTimeout(timer)
reject(signal.reason)
})
}),
[allCategories, category],
)
const results = search({ query, filters: filters.map((f) => f.id), allCategories }, category)
return (
<div className="grid w-full max-w-md gap-3">
<label className="relative">
<span className="sr-only">Search products</span>
<Search
aria-hidden
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
className="h-9 w-full min-w-0 rounded-md border bg-transparent pr-3 pl-9 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30"
/>
</label>
<div className="flex flex-wrap gap-2">
<select
aria-label="Category"
value={allCategories ? "" : category}
onChange={(event) => {
setAllCategories(!event.target.value)
if (event.target.value) setCategory(event.target.value)
}}
className="h-8 rounded-md border bg-transparent px-2 text-sm shadow-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
>
<option value="">All categories</option>
{categories.map((c) => (
<option key={c}>{c}</option>
))}
</select>
<button
type="button"
aria-pressed={budgetOn}
onClick={() => setBudgetOn((on) => !on)}
className="h-8 rounded-md border px-3 text-sm font-medium shadow-xs transition-colors outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:border-foreground/30 aria-pressed:bg-accent"
>
{budget.label}
</button>
</div>
{results.length ? (
<ul className="grid gap-1 text-sm" aria-label="Results">
{results.map((p) => (
<li key={p.name} className="flex justify-between gap-3 rounded-md border px-3 py-2">
<span className="min-w-0 truncate">{p.name}</span>
<span className="shrink-0 text-muted-foreground">₹{p.price.toLocaleString("en-IN")}</span>
</li>
))}
</ul>
) : (
<ZeroResultRescue
query={query}
filters={filters}
scope={allCategories ? undefined : category}
vocabulary={vocabulary}
count={count}
onRescue={(attempt) => {
setQuery(attempt.query)
setBudgetOn(attempt.filters.includes(budget.id))
setAllCategories(allCategories || attempt.allCategories)
}}
/>
)}
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/ui249 lines"use client" // Bollard UI — zero-result-rescue · MIT licence · https://bollard-ui.vercel.app/components/zero-result-rescue import * as React from "react" import { ArrowRight, LoaderCircle, SearchX } from "lucide-react" import { cn } from "@/lib/utils" import { pickRescues, rescueCandidates, RUSSIAN_LAYOUT, type ActiveSearchFilter, type KeyboardLayout, type Rescue, type RescueChange, type SearchAttempt, } from "./rescue" export * from "./rescue" const fill = (template: string, values: Record<string, string | number>) => template.replace(/\{(\w+)\}/g, (match, key: string) => String(values[key] ?? match)) const capitalize = (text: string) => text.replace(/^\p{Ll}/u, (char) => char.toUpperCase()) const DEFAULT_LAYOUTS = [RUSSIAN_LAYOUT] export interface ZeroResultRescueProps extends Omit<React.ComponentProps<"div">, "children"> { /** The query that returned nothing. */ query: string /** Filters applied to that search. */ filters?: ActiveSearchFilter[] /** The category the search was limited to, if any. */ scope?: string /** Words your catalog knows (product names, brands, categories), used for "did you mean". */ vocabulary?: string[] /** Keyboard layouts to try when the query looks typed with the wrong one. */ layouts?: KeyboardLayout[] /** * Returns how many results an attempt would give, or a promise of it (a count-only API call). * Called once per candidate fix, all at once; the signal aborts when the query changes. A * rejected count just drops that fix. */ count: (attempt: SearchAttempt, signal: AbortSignal) => number | Promise<number> /** Run the chosen fix. */ onRescue: (attempt: SearchAttempt, rescue: Rescue) => void /** Most fixes to offer. */ limit?: number /** Templates: `{query}`, `{scope}`, `{filters}`, `{count}`, `{filter}`, `{layout}`, `{from}`, `{to}` are filled in. */ titleText?: string /** Text naming the current category scope. */ scopeText?: string /** Label prefix of a search-instead fix. */ searchLabel?: string /** Label prefix of a remove-filter fix. */ withoutLabel?: string /** Label of the search-all-categories fix. */ allCategoriesLabel?: string /** Reason shown for a spelling fix. */ spellingReason?: string /** Reason shown for a keyboard-layout fix. */ layoutReason?: string /** Reason shown for a Caps Lock fix. */ capsReason?: string /** Reason shown for a filter fix. */ filterReason?: string /** Reason shown for a category fix. */ scopeReason?: string /** Shown when no single change brings results back. */ fallbackText?: string /** Formats a result count. */ formatCount?: (count: number) => string /** "1 filter" / "2 filters" under the title. */ filtersText?: (count: number) => string /** Shown while async counts are coming in. */ checkingText?: string /** Fixes are recounted when the query, filters, scope, vocabulary or layouts change; change this too when the data behind `count` does. */ countKey?: string | number } const defaultCount = (count: number) => `${count.toLocaleString()} ${count === 1 ? "result" : "results"}` /** * A "no results" state that explains why nothing matched and offers only fixes * that are proven to return results: a spelling correction from your own * vocabulary, the query retyped in the right keyboard layout or without Caps * Lock, dropping the one filter that hides everything, or widening to all * categories. Each fix shows its result count, so no tap leads to another empty page. */ export function ZeroResultRescue({ query, filters = [], scope, vocabulary = [], layouts = DEFAULT_LAYOUTS, count, onRescue, limit = 3, titleText = "No results for “{query}”", scopeText = "in {scope}", searchLabel = "Search “{query}”", withoutLabel = "without {filter}", allCategoriesLabel = "in all categories", spellingReason = "“{from}” looks like “{to}”", layoutReason = "typed with the {layout} keyboard layout", capsReason = "Caps Lock looks on", filterReason = "{filter} hides every match", scopeReason = "nothing in {scope}", fallbackText = "Nothing close turned up. Try fewer or more general words.", formatCount = defaultCount, filtersText = (n) => `${n} ${n === 1 ? "filter" : "filters"}`, checkingText = "Looking for close matches…", countKey, className, ...props }: ZeroResultRescueProps) { // Inline arrays and objects change identity every render; key the work on their contents instead. const inputKey = JSON.stringify([query, filters, scope, vocabulary, layouts.map((l) => l.name), limit, countKey]) const candidates = React.useMemo( () => rescueCandidates({ query, filters, scope, vocabulary, layouts }), // eslint-disable-next-line react-hooks/exhaustive-deps [inputKey], ) const countRef = React.useRef(count) React.useEffect(() => { countRef.current = count }) const [result, setResult] = React.useState<{ key: string; rescues: Rescue[] } | null>(null) // A layout effect, so in-memory counts settle before paint and never flash the checking line. React.useLayoutEffect(() => { const controller = new AbortController() const counts = candidates.map((candidate) => { try { return countRef.current(candidate.attempt, controller.signal) } catch { return 0 } }) const settle = (values: number[]) => !controller.signal.aborted && setResult({ key: inputKey, rescues: pickRescues(candidates, values, limit) }) if (counts.every((n) => typeof n === "number")) settle(counts as number[]) else void Promise.all(counts.map((n) => Promise.resolve(n).catch(() => 0))).then(settle) return () => controller.abort() }, [candidates, inputKey, limit]) const checking = result?.key !== inputKey const rescues = checking ? [] : result.rescues const reason = (change: RescueChange) => { if (change.kind === "spelling") return change.words.map(([from, to]) => fill(spellingReason, { from, to })).join(", ") if (change.kind === "layout") return fill(layoutReason, { layout: change.layout }) if (change.kind === "caps") return capsReason if (change.kind === "filter") return fill(filterReason, { filter: change.label }) return fill(scopeReason, { scope: scope ?? "" }) } const context = [ scope && fill(scopeText, { scope }), filters.length && filtersText(filters.length), ] .filter(Boolean) .join(" · ") return ( <div data-slot="zero-result-rescue" data-state={checking ? "checking" : rescues.length ? "rescuable" : "empty"} aria-busy={checking || undefined} role="status" className={cn("grid w-full max-w-md gap-3 rounded-lg border border-dashed p-4", className)} {...props} > <style>{`@keyframes cui-zero-result-rescue-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}`}</style> <div className="flex gap-3"> <SearchX aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" /> <div className="min-w-0"> <p className="text-sm font-medium break-words">{fill(titleText, { query })}</p> {context && <p className="text-xs text-muted-foreground">{context}</p>} </div> </div> {checking ? ( <p className="flex items-center gap-2 text-sm text-muted-foreground"> <LoaderCircle aria-hidden className="size-4 animate-spin" /> {checkingText} </p> ) : rescues.length ? ( <ul key={query} className="grid gap-1.5"> {rescues.map((rescue, index) => { const spelling = rescue.changes.find((change) => change.kind === "spelling") const fixed = new Set(spelling?.kind === "spelling" ? spelling.words.map(([, to]) => to.toLowerCase()) : []) const removed = rescue.changes.filter((change) => change.kind === "filter") return ( <li key={rescue.changes.map((change) => change.kind + (change.kind === "filter" ? change.id : "")).join()} style={{ animationDelay: `${index * 60}ms` }} className="motion-safe:animate-[cui-zero-result-rescue-in_250ms_ease-out_both]" > <button type="button" onClick={() => onRescue(rescue.attempt, rescue)} className="group grid w-full grid-cols-[1fr_auto] items-start gap-x-3 gap-y-0.5 rounded-md border bg-background px-3 py-2 text-left shadow-xs transition-colors outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50" > <span className="min-w-0 text-sm font-medium break-words"> {searchLabel .split("{query}") .flatMap((part, i) => (i ? [<Query key={i} text={rescue.attempt.query} fixed={fixed} />, part] : [part]))} {removed.map((change) => change.kind === "filter" ? ` ${fill(withoutLabel, { filter: change.label })}` : "", )} {rescue.attempt.allCategories && ` ${allCategoriesLabel}`} </span> <span className="flex shrink-0 items-center gap-1 pt-0.5 text-xs font-medium whitespace-nowrap text-emerald-700 dark:text-emerald-400"> {formatCount(rescue.count)} <ArrowRight aria-hidden className="size-3.5 transition-transform group-hover:translate-x-0.5 motion-reduce:transition-none" /> </span> <span className="col-span-2 text-xs text-muted-foreground"> {capitalize(rescue.changes.map(reason).join(" · "))} </span> </button> </li> ) })} </ul> ) : ( <p className="text-sm text-muted-foreground">{fallbackText}</p> )} </div> ) } /** The repaired query with corrected words highlighted. */ function Query({ text, fixed }: { text: string; fixed: Set<string> }) { return ( <> {text.split(/([\p{L}\p{N}]+)/u).map((part, index) => fixed.has(part.toLowerCase()) ? ( <mark key={index} className="rounded-sm bg-amber-300/50 px-px text-inherit dark:bg-amber-400/25"> {part} </mark> ) : ( part ), )} </> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { ZeroResultRescue, type SearchAttempt } from "@/components/ui/zero-result-rescue"
<ZeroResultRescue
query={query}
filters={filters}
scope={allCategories ? undefined : category}
vocabulary={vocabulary}
count={count}
onRescue={(attempt) => {
setQuery(attempt.query)
setBudgetOn(attempt.filters.includes(budget.id))
setAllCategories(allCategories || attempt.allCategories)
}}
/>What it catches
- Misspellings against your own vocabulary
- Wrong keyboard layout and Caps Lock
- The single filter that hides every result
- A category scope that's too narrow
- Only fixes with a non-zero count are shown
Props
| Prop | Type | Default |
|---|---|---|
query* (required)The query that returned nothing. | string | — |
filtersFilters applied to that search. | ActiveSearchFilter[] | [] |
scopeThe category the search was limited to, if any. | string | — |
vocabularyWords your catalog knows (product names, brands, categories), used for "did you mean". | string[] | [] |
layoutsKeyboard layouts to try when the query looks typed with the wrong one. | KeyboardLayout[] | DEFAULT_LAYOUTS |
count* (required)Returns how many results an attempt would give, or a promise of it (a count-only API call). Called once per candidate fix, all at once; the signal aborts when the query changes. A rejected count just drops that fix. | (attempt: SearchAttempt, signal: AbortSignal) => number | Promise<number> | — |
onRescue* (required)Run the chosen fix. | (attempt: SearchAttempt, rescue: Rescue) => void | — |
limitMost fixes to offer. | number | 3 |
titleTextTemplates: `{query}`, `{scope}`, `{filters}`, `{count}`, `{filter}`, `{layout}`, `{from}`, `{to}` are filled in. | string | "No results for “{query}”" |
scopeTextText naming the current category scope. | string | "in {scope}" |
searchLabelLabel prefix of a search-instead fix. | string | "Search “{query}”" |
withoutLabelLabel prefix of a remove-filter fix. | string | "without {filter}" |
allCategoriesLabelLabel of the search-all-categories fix. | string | "in all categories" |
spellingReasonReason shown for a spelling fix. | string | "“{from}” looks like “{to}”" |
layoutReasonReason shown for a keyboard-layout fix. | string | "typed with the {layout} keyboard layout" |
capsReasonReason shown for a Caps Lock fix. | string | "Caps Lock looks on" |
filterReasonReason shown for a filter fix. | string | "{filter} hides every match" |
scopeReasonReason shown for a category fix. | string | "nothing in {scope}" |
fallbackTextShown when no single change brings results back. | string | "Nothing close turned up. Try fewer or more general words." |
formatCountFormats a result count. | (count: number) => string | defaultCount |
filtersText"1 filter" / "2 filters" under the title. | (count: number) => string | (n) => `${n} ${n === 1 ? "filter" : "filters"}` |
checkingTextShown while async counts are coming in. | string | "Looking for close matches…" |
countKeyFixes are recounted when the query, filters, scope, vocabulary or layouts change; change this too when the data behind `count` does. | string | number | — |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- Region is role=status with aria-busy while counting
- Fix buttons include their reason and count as text
- Icons are aria-hidden
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- Counts come from your count function; slow APIs delay suggestions
- Did-you-mean is limited to the vocabulary you pass
What it stops
Users quit on empty search results
How people describe it
Also known as
no results · empty state · search · did you mean · zero results · filters · search page · ecommerce search
Field type