Search empty states
Five no-results placeholders written for search: the query echoed in the title, the active filters with a clear-all action, suggested alternative terms, recent searches, and a clear-and-retry panel. Each is sized, token-driven and renders with default copy.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/empty-searchDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install lucide-reactCopy the source
components/ui/empty-search.tsx"use client"
import type * as React from "react"
import {
Clock,
Filter,
Lightbulb,
Search,
SearchX,
X,
} from "lucide-react"
import { cn } from "@/lib/utils"
/* Search empty family: 5 "no results" empty states. All are specific to a search context: an echo of the query, active filter badges, suggestion chips, recent searches and a clear-filters action. Colour comes ONLY from semantic tokens (transparency via color-mix), size = the padding plus icon and text scale. Renders without props too: the default title, description and a sample query come filled in. Static - no motion. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
type Scale = {
root: string
icon: string
badge: string
title: string
description: string
}
const scales: Record<StyledSize, Scale> = {
sm: {
root: "gap-2 p-6",
icon: "size-6",
badge: "size-12",
title: "text-sm",
description: "text-xs",
},
md: {
root: "gap-3 p-8",
icon: "size-8",
badge: "size-16",
title: "text-base",
description: "text-sm",
},
lg: {
root: "gap-3.5 p-10",
icon: "size-10",
badge: "size-20",
title: "text-lg",
description: "text-sm",
},
xl: {
root: "gap-4 p-12",
icon: "size-12",
badge: "size-24",
title: "text-xl",
description: "text-base",
},
}
interface EmptyProps {
className?: string
size?: StyledSize
icon?: React.ReactNode
title?: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
/** Yankilanan arama sorgusu. */
query?: string
}
const rootBase = "flex flex-col items-center justify-center text-center"
const iconBase = "[&_i]:leading-none [&_svg]:size-full"
const badgeBase =
"flex items-center justify-center rounded-full border border-border text-muted-foreground [background:color-mix(in_oklab,var(--color-foreground)_6%,transparent)]"
/* Ortak aksiyon butonu: focus ring + ikon uyumu (svg + i). */
const actionBtn =
"inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground outline-none transition-colors duration-(--motion-base) ease-(--motion-ease) hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none [&_i]:text-base [&_i]:leading-none [&_svg]:size-4 [&_svg]:shrink-0"
const ghostBtn =
"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-border bg-transparent px-4 text-sm font-medium text-foreground outline-none transition-colors duration-(--motion-base) ease-(--motion-ease) hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none [&_i]:text-base [&_i]:leading-none [&_svg]:size-4 [&_svg]:shrink-0"
/* A small clickable chip: used in the suggestion / recent-search lists. */
const chip =
"inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-xs text-muted-foreground outline-none transition-colors duration-(--motion-base) ease-(--motion-ease) hover:border-[color-mix(in_oklab,var(--color-primary)_40%,transparent)] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none [&_i]:text-xs [&_i]:leading-none [&_svg]:size-3 [&_svg]:shrink-0"
/* QueryEmpty: sorguyu basligin icinde tirnak icinde yankilar. */
export function QueryEmpty({
className,
size = "md",
icon,
title,
description = "We could not match that search. Check the spelling or try a broader term.",
action,
query = "quarterly report",
}: EmptyProps) {
const s = scales[size]
return (
<div data-slot="styled-empty" className={cn(rootBase, s.root, className)}>
<span
aria-hidden="true"
className={cn("text-muted-foreground", iconBase, s.icon)}
>
{icon ?? <SearchX />}
</span>
<p className={cn("font-semibold text-foreground", s.title)}>
{title ?? (
<>
No results for{" "}
<span className="text-primary">“{query}”</span>
</>
)}
</p>
{description ? (
<p className={cn("max-w-prose text-muted-foreground", s.description)}>
{description}
</p>
) : null}
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}
/* FilterEmpty: aktif filtreleri rozet olarak listeler + tumunu temizle aksiyonu. */
export function FilterEmpty({
className,
size = "md",
icon,
title = "No items match your filters",
description = "Your current filters are too narrow. Remove one to widen the results.",
action,
}: EmptyProps) {
const s = scales[size]
const filters = ["Status: Archived", "Owner: Me", "Last 7 days"]
return (
<div data-slot="styled-empty" className={cn(rootBase, s.root, className)}>
<span aria-hidden="true" className={cn(badgeBase, s.badge)}>
<span className={cn(iconBase, s.icon)}>{icon ?? <Filter />}</span>
</span>
<p className={cn("font-semibold text-foreground", s.title)}>{title}</p>
{description ? (
<p className={cn("max-w-prose text-muted-foreground", s.description)}>
{description}
</p>
) : null}
<div className="mt-1 flex flex-wrap items-center justify-center gap-1.5">
{filters.map((f) => (
<span
key={f}
className="inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-xs text-muted-foreground [background:color-mix(in_oklab,var(--color-foreground)_4%,transparent)]"
>
{f}
</span>
))}
</div>
<div className="mt-2">
{action ?? (
<button type="button" data-slot="styled-empty-action" className={ghostBtn}>
<X />
Clear all filters
</button>
)}
</div>
</div>
)
}
/* SuggestEmpty: sorgu yankisi + tiklanabilir alternatif arama cipleri. */
export function SuggestEmpty({
className,
size = "md",
icon,
title = "No results found",
description = "Nothing matched that search. One of these might be what you meant.",
action,
query = "invoicing",
}: EmptyProps) {
const s = scales[size]
const suggestions = ["invoices", "billing", "payments"]
return (
<div data-slot="styled-empty" className={cn(rootBase, s.root, className)}>
<span
aria-hidden="true"
className={cn("text-muted-foreground", iconBase, s.icon)}
>
{icon ?? <Lightbulb />}
</span>
<p className={cn("font-semibold text-foreground", s.title)}>{title}</p>
{description ? (
<p className={cn("max-w-prose text-muted-foreground", s.description)}>
{description}
</p>
) : null}
<p className="text-xs text-muted-foreground">
You searched for “{query}”
</p>
<div className="mt-1 flex flex-wrap items-center justify-center gap-1.5">
{suggestions.map((sug) => (
<button key={sug} type="button" className={chip}>
<Search />
{sug}
</button>
))}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}
/* RecentEmpty: sonuc yokken son aramalari hatirlatir. */
export function RecentEmpty({
className,
size = "md",
icon,
title = "Nothing to show yet",
description = "Start typing to search, or pick up one of your recent searches.",
action,
}: EmptyProps) {
const s = scales[size]
const recents = ["design tokens", "release notes", "team roster"]
return (
<div data-slot="styled-empty" className={cn(rootBase, s.root, className)}>
<span aria-hidden="true" className={cn(badgeBase, s.badge)}>
<span className={cn(iconBase, s.icon)}>{icon ?? <Clock />}</span>
</span>
<p className={cn("font-semibold text-foreground", s.title)}>{title}</p>
{description ? (
<p className={cn("max-w-prose text-muted-foreground", s.description)}>
{description}
</p>
) : null}
<div className="mt-1 flex flex-wrap items-center justify-center gap-1.5">
{recents.map((r) => (
<button key={r} type="button" className={chip}>
<Clock />
{r}
</button>
))}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}
/* ClearEmpty: sorgu rozeti + birincil "temizle ve tekrar dene" aksiyonu. */
export function ClearEmpty({
className,
size = "md",
icon,
title = "That search came up empty",
description = "Clear the query to browse everything, or refine it and try again.",
action,
query = "acme corp 2025",
}: EmptyProps) {
const s = scales[size]
return (
<div data-slot="styled-empty" className={cn(rootBase, s.root, className)}>
<span
aria-hidden="true"
className={cn(
"flex items-center justify-center rounded-2xl border text-primary [background:color-mix(in_oklab,var(--color-primary)_10%,transparent)] [border-color:color-mix(in_oklab,var(--color-primary)_28%,transparent)]",
s.badge
)}
>
<span className={cn(iconBase, s.icon)}>{icon ?? <Search />}</span>
</span>
<p className={cn("font-semibold text-foreground", s.title)}>{title}</p>
<span className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 font-mono text-xs text-muted-foreground [background:color-mix(in_oklab,var(--color-foreground)_4%,transparent)]">
{query}
</span>
{description ? (
<p className={cn("max-w-prose text-muted-foreground", s.description)}>
{description}
</p>
) : null}
<div className="mt-2">
{action ?? (
<button type="button" data-slot="styled-empty-action" className={actionBtn}>
<X />
Clear search
</button>
)}
</div>
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Query
The search term is echoed back inside the title.
No results for “quarterly report”
We could not match that search. Check the spelling or try a broader term.
import { QueryEmpty } from "@/components/ui/empty-search"
<QueryEmpty />No results for “quarterly report”
We could not match that search. Check the spelling or try a broader term.
No results for “quarterly report”
We could not match that search. Check the spelling or try a broader term.
No results for “quarterly report”
We could not match that search. Check the spelling or try a broader term.
No results for “quarterly report”
We could not match that search. Check the spelling or try a broader term.
Filter
The active filters are listed with a clear-all action.
No items match your filters
Your current filters are too narrow. Remove one to widen the results.
import { FilterEmpty } from "@/components/ui/empty-search"
<FilterEmpty />No items match your filters
Your current filters are too narrow. Remove one to widen the results.
No items match your filters
Your current filters are too narrow. Remove one to widen the results.
No items match your filters
Your current filters are too narrow. Remove one to widen the results.
No items match your filters
Your current filters are too narrow. Remove one to widen the results.
Suggest
Alternative search terms are offered as chips.
No results found
Nothing matched that search. One of these might be what you meant.
You searched for “invoicing”
import { SuggestEmpty } from "@/components/ui/empty-search"
<SuggestEmpty />No results found
Nothing matched that search. One of these might be what you meant.
You searched for “invoicing”
No results found
Nothing matched that search. One of these might be what you meant.
You searched for “invoicing”
No results found
Nothing matched that search. One of these might be what you meant.
You searched for “invoicing”
No results found
Nothing matched that search. One of these might be what you meant.
You searched for “invoicing”
Recent
Recent searches are surfaced while nothing matches.
Nothing to show yet
Start typing to search, or pick up one of your recent searches.
import { RecentEmpty } from "@/components/ui/empty-search"
<RecentEmpty />Nothing to show yet
Start typing to search, or pick up one of your recent searches.
Nothing to show yet
Start typing to search, or pick up one of your recent searches.
Nothing to show yet
Start typing to search, or pick up one of your recent searches.
Nothing to show yet
Start typing to search, or pick up one of your recent searches.
Clear
The query sits in a badge above a primary clear action.
That search came up empty
acme corp 2025Clear the query to browse everything, or refine it and try again.
import { ClearEmpty } from "@/components/ui/empty-search"
<ClearEmpty />That search came up empty
acme corp 2025Clear the query to browse everything, or refine it and try again.
That search came up empty
acme corp 2025Clear the query to browse everything, or refine it and try again.
That search came up empty
acme corp 2025Clear the query to browse everything, or refine it and try again.
That search came up empty
acme corp 2025Clear the query to browse everything, or refine it and try again.
ai2 Search empty states: 5 styled variations on the token system
The ai2 Search empty states are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around no-results empty states for search and filtering. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: these are static; no motion library work is required. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, there is no motion to reduce.
What is in the ai2 Search empty states?
5 exports in one file: Query, Filter, Suggest, Recent and Clear. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: these are static; no motion library work is required.
- Reduced-motion aware: Under prefers-reduced-motion, there is no motion to reduce.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Search empty states sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.