Empty State With Illustration
Two variants — nothing created yet, versus filters excluded everything — because offering "create your first" to a bad filter is the classic bug.
The empty state for a list emptied by its own filters: every active filter shown as a removable chip ordered by how many results it would restore, with clear-all deliberately second.
components/empty-filtered-results.tsxnpx hoverlab add empty-filtered-results
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Nothing matched “renewal” with 4 filters applied. 1,284 records exist in total.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <EmptyFilteredResults> — nothing found, and the filters that caused it.
*
* The catalog has an empty state with a call to action and a "no search
* results" pattern inside the search block. Neither covers the case that
* actually happens most: a list that is empty because of four filters the
* user set twenty minutes ago and has forgotten about.
*
* WHY THAT DESERVES ITS OWN BLOCK
*
* The generic empty state says "No results — try a different search", which
* is advice, and unhelpful advice at that. The user does not need to be
* told to change something; they need to be told *what is currently
* excluding everything*, and given a one-click way to remove it. Those are
* different components with different content, and shipping the first where
* the second belongs is why people abandon filtered views.
*
* THE NARROWEST FILTER IS OFFERED FIRST
*
* Removing filters one at a time in the order they were applied is guessing.
* When the caller knows how many results each filter would restore, the
* chips are ordered by that number, so the first thing offered is the one
* most likely to fix it. That ordering is the entire value of this block
* over a list of chips with an × on each.
*
* "CLEAR ALL" IS NOT THE PRIMARY ACTION. It is available and it is second,
* because clearing everything throws away work the user did on purpose —
* and after clearing they are looking at an unfiltered list they never
* asked for.
*
* ACCESSIBILITY: the region is `role="status"` so the transition into
* emptiness is announced; each chip's button names the filter it removes
* rather than being an unlabelled ×.
*/
import * as React from 'react'
import { FilterX, RotateCcw, SearchX, X } from 'lucide-react'
export interface ActiveFilter {
id: string
/** Facet name, e.g. "Status". */
field: string
/** Chosen value, e.g. "Archived". */
value: string
/**
* Results that would come back if this one filter were removed.
*
* Optional because a caller that cannot cheaply compute it should not be
* forced to lie — when it is absent the chips keep their given order and
* no count is shown.
*/
restores?: number
}
export interface EmptyFilteredResultsProps {
filters?: ActiveFilter[]
/** Total rows before any filtering. */
totalUnfiltered?: number
query?: string
className?: string
}
const DEFAULT_FILTERS: ActiveFilter[] = [
{ id: 'status', field: 'Status', value: 'Archived', restores: 148 },
{ id: 'owner', field: 'Owner', value: 'Jordan Lee', restores: 12 },
{ id: 'plan', field: 'Plan', value: 'Enterprise', restores: 31 },
{ id: 'created', field: 'Created', value: 'Last 7 days', restores: 96 },
]
export function EmptyFilteredResults({
filters = DEFAULT_FILTERS,
totalUnfiltered = 1_284,
query = 'renewal',
className = '',
}: EmptyFilteredResultsProps) {
const [active, setActive] = React.useState(filters)
const remove = (id: string) => setActive((current) => current.filter((f) => f.id !== id))
/*
* Sorted by what each removal would bring back, biggest first. `restores`
* is optional, so anything without a count keeps its position at the end
* rather than sorting as zero and jumping to the front.
*/
const ordered = [...active].sort((a, b) => (b.restores ?? -1) - (a.restores ?? -1))
const best = ordered.find((filter) => filter.restores !== undefined)
return (
<section
role="status"
className={`rounded-2xl border border-border bg-card p-8 text-center text-card-foreground sm:p-12 ${className}`}
>
<span
aria-hidden
className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted"
>
<SearchX className="h-5 w-5 text-muted-foreground" />
</span>
<h2 className="mt-5 text-lg font-semibold">No results match these filters</h2>
<p className="mx-auto mt-2 max-w-md text-pretty text-sm text-muted-foreground">
{query ? (
<>
Nothing matched <span className="font-medium text-foreground">“{query}”</span> with{' '}
</>
) : (
'Nothing matched with '
)}
{active.length} {active.length === 1 ? 'filter' : 'filters'} applied.{' '}
{totalUnfiltered.toLocaleString('en-US')} records exist in total.
</p>
{/* The filters themselves, which is the content the generic empty
state does not have. */}
{active.length > 0 ? (
<>
<ul className="mx-auto mt-6 flex max-w-lg flex-wrap justify-center gap-2">
{ordered.map((filter) => (
<li key={filter.id}>
<button
type="button"
onClick={() => remove(filter.id)}
className="group inline-flex items-center gap-1.5 rounded-full border border-border bg-background py-1 ps-3 pe-1.5 text-sm transition-colors hover:border-primary/50 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="text-muted-foreground">{filter.field}:</span>
<span className="font-medium">{filter.value}</span>
{filter.restores !== undefined ? (
<span className="text-xs text-muted-foreground">
+{filter.restores.toLocaleString('en-US')}
</span>
) : null}
<span
aria-hidden
className="ms-0.5 flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground transition-colors group-hover:bg-muted group-hover:text-foreground"
>
<X className="h-3 w-3" />
</span>
<span className="sr-only">
Remove the {filter.field} filter
{filter.restores !== undefined
? `, which would show ${filter.restores} results`
: ''}
</span>
</button>
</li>
))}
</ul>
<div className="mt-7 flex flex-wrap items-center justify-center gap-3">
{best ? (
<button
type="button"
onClick={() => remove(best.id)}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<FilterX aria-hidden className="h-4 w-4" />
Remove “{best.field}: {best.value}”
<span className="font-normal opacity-80">
— {best.restores?.toLocaleString('en-US')} results
</span>
</button>
) : null}
{/* Second, deliberately. Clearing everything discards work. */}
<button
type="button"
onClick={() => setActive([])}
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw aria-hidden className="h-4 w-4" />
Clear all filters
</button>
</div>
</>
) : (
<p className="mt-6 text-sm text-muted-foreground">
All filters cleared — showing every record.
</p>
)}
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/empty-filtered-results.tsx and import it where you need the section:
import { EmptyFilteredResults } from '@/components/empty-filtered-results'1 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
filters | ActiveFilter[] | DEFAULT_FILTERS |
totalUnfilteredTotal rows before any filtering. | number | 1_284 |
query | string | 'renewal' |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Empty Because of Filters — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section role="status" class="rounded-2xl border border-border bg-card p-8 text-center text-card-foreground sm:p-12 ">
<span aria-hidden="true" class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-x h-5 w-5 text-muted-foreground" aria-hidden="true">
<path d="m13.5 8.5-5 5"></path>
<path d="m8.5 8.5 5 5"></path>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
</span>
<h2 class="mt-5 text-lg font-semibold">No results match these filters</h2>
<p class="mx-auto mt-2 max-w-md text-pretty text-sm text-muted-foreground">Nothing matched <span class="font-medium text-foreground">“renewal”</span> with 4 filters applied. 1,284 records exist in total.</p>
<ul class="mx-auto mt-6 flex max-w-lg flex-wrap justify-center gap-2">
<li>
<button type="button" class="group inline-flex items-center gap-1.5 rounded-full border border-border bg-background py-1 ps-3 pe-1.5 text-sm transition-colors hover:border-primary/50 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="text-muted-foreground">Status:</span>
<span class="font-medium">Archived</span>
<span class="text-xs text-muted-foreground">+148</span>
<span aria-hidden="true" class="ms-0.5 flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground transition-colors group-hover:bg-muted group-hover:text-foreground">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-3 w-3" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</span>
<span class="sr-only">Remove the Status filter, which would show 148 results</span>
</button>
</li>
<li>
<button type="button" class="group inline-flex items-center gap-1.5 rounded-full border border-border bg-background py-1 ps-3 pe-1.5 text-sm transition-colors hover:border-primary/50 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="text-muted-foreground">Created:</span>
<span class="font-medium">Last 7 days</span>
<span class="text-xs text-muted-foreground">+96</span>
<span aria-hidden="true" class="ms-0.5 flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground transition-colors group-hover:bg-muted group-hover:text-foreground">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-3 w-3" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</span>
<span class="sr-only">Remove the Created filter, which would show 96 results</span>
</button>
</li>
<li>
<button type="button" class="group inline-flex items-center gap-1.5 rounded-full border border-border bg-background py-1 ps-3 pe-1.5 text-sm transition-colors hover:border-primary/50 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="text-muted-foreground">Plan:</span>
<span class="font-medium">Enterprise</span>
<span class="text-xs text-muted-foreground">+31</span>
<span aria-hidden="true" class="ms-0.5 flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground transition-colors group-hover:bg-muted group-hover:text-foreground">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-3 w-3" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</span>
<span class="sr-only">Remove the Plan filter, which would show 31 results</span>
</button>
</li>
<li>
<button type="button" class="group inline-flex items-center gap-1.5 rounded-full border border-border bg-background py-1 ps-3 pe-1.5 text-sm transition-colors hover:border-primary/50 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="text-muted-foreground">Owner:</span>
<span class="font-medium">Jordan Lee</span>
<span class="text-xs text-muted-foreground">+12</span>
<span aria-hidden="true" class="ms-0.5 flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground transition-colors group-hover:bg-muted group-hover:text-foreground">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-3 w-3" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</span>
<span class="sr-only">Remove the Owner filter, which would show 12 results</span>
</button>
</li>
</ul>
<div class="mt-7 flex flex-wrap items-center justify-center gap-3">
<button type="button" class="inline-flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-funnel-x h-4 w-4" aria-hidden="true"><path d="M12.531 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14v6a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341l.427-.473"></path><path d="m16.5 3.5 5 5"></path><path d="m21.5 3.5-5 5"></path></svg>Remove “Status: Archived”<span class="font-normal opacity-80">— 148 results</span></button>
<button type="button" class="inline-flex items-center gap-1.5 rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw h-4 w-4" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg>Clear all filters</button>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse Empty & Error StatesCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
Two variants — nothing created yet, versus filters excluded everything — because offering "create your first" to a bad filter is the classic bug.
Failed-to-load panel with a visible retry, a copyable error reference and the stack tucked behind a disclosure.
A 404 that helps: a real GET search form that works without JavaScript, likely destinations, and home as the fallback rather than the headline.
Loading placeholders with varied row widths and a sweep that is motion-safe only, falling back to a flat tint under reduced motion.
The state where the page exists and the account is not on the list: names who can grant access, sends the request, and leaks nothing about what is inside.
Leads with what still works — three edits saved on this device — instead of with the failure. The retry backoff counts down in the open, and the three things that genuinely cannot work offline are named rather than discovered.
Planned downtime with a live countdown, an end time in the reader’s timezone, what still works — and a different sentence once the window overruns rather than a negative timer.