Browse/component
Empty results
A no-match state that names the filters that emptied the grid, offers the single change that brings the most back, and lets shoppers remove filters one at a time. Left-aligned, never centred.
Step 1Register the namespace — once per project
$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.jsonStep 2Add the block
$ npx shadcn@latest add @ecomcn/empty-resultsPreview — live
100%
Usage
As the grid's empty stateimport { EmptyResults } from "@/components/ecomcn/empty-results"
import { ProductGrid } from "@/components/ecomcn/product-grid"
import { describeActiveFilters, removeFilter } from "@/lib/filter-params" // from filter-panel
const active = describeActiveFilters(filters, facets)
<ProductGrid
products={products}
empty={
<EmptyResults
filters={active.map((f) => ({
id: `${f.facetId}:${f.value ?? ""}`,
label: f.label,
onRemove: () => setFilters(removeFilter(filters, f.facetId, f.value)),
}))}
// The single change that brings the most back — ask your search backend.
suggestion={{ label: "Remove “Up to $50”", count: 4, onApply: widenPrice }}
onClearAll={() => setFilters({})}
/>
}
/>Props
Read straight out of the source at build time, so this cannot drift from the file you install.
EmptyResultsProps/** The search term, when there is one. */
query?: string
/** The filters that are on. Named in the headline and removable one by one. */
filters?: EmptyResultsFilter[]
suggestion?: EmptyResultsSuggestion
onClearAll?: () => void
/** Overrides the generated headline. */
title?: React.ReactNode
description?: React.ReactNode
locale?: string
noun?: { one: string; other: string }Source
components/ecomcn/empty-results.tsximport * as React from "react"
import { ArrowRight, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export interface EmptyResultsFilter {
/** Stable key, e.g. `color:sage`. */
id: string
/** What the shopper sees — "Sage", "Up to $80", "In stock only". */
label: string
onRemove?: () => void
}
export interface EmptyResultsSuggestion {
/** The one change that helps most, as an action: "Remove “Up to $80”". */
label: string
/** How many results that change brings back. */
count?: number
onApply: () => void
}
export interface EmptyResultsProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
/** The search term, when there is one. */
query?: string
/** The filters that are on. Named in the headline and removable one by one. */
filters?: EmptyResultsFilter[]
suggestion?: EmptyResultsSuggestion
onClearAll?: () => void
/** Overrides the generated headline. */
title?: React.ReactNode
description?: React.ReactNode
locale?: string
noun?: { one: string; other: string }
}
/**
* "No results" tells a shopper nothing. This names what emptied the grid and
* offers the single change that brings the most back — the difference
* between a dead end and a detour.
*/
export function EmptyResults({
query,
filters = [],
suggestion,
onClearAll,
title,
description,
locale,
noun = { one: "product", other: "products" },
className,
children,
...props
}: EmptyResultsProps) {
// Quoted, because filter labels are tokens ("Up to $80", "In stock only")
// and read badly as bare words in the middle of a sentence.
const list = new Intl.ListFormat(locale, { type: "conjunction" }).format(
filters.map((f) => `“${f.label}”`)
)
const headline =
title ??
(query && filters.length
? `Nothing for “${query}” in ${list}.`
: query
? `Nothing for “${query}”.`
: filters.length
? `Nothing matches ${list}.`
: "Nothing here yet.")
const counted = (n: number) =>
`${new Intl.NumberFormat(locale).format(n)} ${
new Intl.PluralRules(locale).select(n) === "one" ? noun.one : noun.other
}`
return (
// Left-aligned on purpose. A centred icon-and-sentence empty state is the
// most reliable tell of a generated interface.
<div
className={cn("flex flex-col items-start gap-5 py-10 sm:py-14", className)}
{...props}
>
<p className="text-xs font-medium tracking-[0.14em] text-muted-foreground uppercase">
No results
</p>
<h2 className="max-w-2xl text-2xl leading-tight text-balance sm:text-3xl">
{headline}
</h2>
{description ? (
<p className="max-w-lg text-sm leading-relaxed text-muted-foreground">
{description}
</p>
) : null}
{suggestion ? (
<Button type="button" onClick={suggestion.onApply} className="h-10 gap-2 text-[13px]">
{suggestion.label}
{suggestion.count !== undefined && (
<span className="tabular-nums opacity-70">· {counted(suggestion.count)}</span>
)}
<ArrowRight className="size-4" aria-hidden />
</Button>
) : null}
{filters.some((f) => f.onRemove) || onClearAll ? (
<div className="flex flex-wrap items-center gap-1.5 text-sm">
<span className="mr-1 text-muted-foreground">
{suggestion ? "Or remove one:" : "Remove one:"}
</span>
{filters.map((filter) =>
filter.onRemove ? (
<button
key={filter.id}
type="button"
onClick={filter.onRemove}
className="group inline-flex h-7 items-center gap-1.5 rounded-sm border bg-secondary/60 pr-1.5 pl-2 text-xs transition-colors outline-none hover:border-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="sr-only">Remove filter:</span>
{filter.label}
<X className="size-3 text-muted-foreground group-hover:text-foreground" aria-hidden />
</button>
) : null
)}
{onClearAll ? (
<button
type="button"
onClick={onClearAll}
className="ml-1 text-xs underline underline-offset-4 outline-none hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
Clear all
</button>
) : null}
</div>
) : null}
{children}
</div>
)
}