ecomcn
All blocks

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}.json

Step 2Add the block

$ npx shadcn@latest add @ecomcn/empty-results
Preview — live
100%

Usage

As the grid's empty state
import { 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.tsx
import * 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>
  )
}