Empty Search
Zero-state illustration of a magnifier sweeping over empty result rows, with optional title and description.
Live preview and source code for Empty Search
"use client"
import React from "react";
import { motion } from "framer-motion";
export interface EmptySearchProps {
/** Rendered width of the illustration */
size?: number;
/** Accent colour used for the magnifier and highlights */
accent?: string;
/** Headline under the artwork. Pass `null` for artwork only */
title?: string | null;
/** Supporting line under the title */
description?: string | null;
/** Stops the sweep and float animations */
still?: boolean;
className?: string;
}
/**
* EmptySearch — a zero-state illustration of a magnifier sweeping over
* empty result rows. Renders standalone with zero props.
*/
export default function EmptySearch({
size = 260,
accent = "#7c3aed",
title = "No results found",
description = "Try a different search term or clear your filters.",
still = false,
className = "",
}: EmptySearchProps) {
const float = still
? undefined
: { y: [0, -6, 0], rotate: [0, -3, 0] };
return (
<div
className={className}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 6,
fontFamily: "inherit",
color: "#18181b",
textAlign: "center",
}}
>
<svg
width={size}
height={size * 0.78}
viewBox="0 0 260 204"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="No search results"
>
<defs>
<linearGradient id="es-card" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#ffffff" />
<stop offset="100%" stopColor="#f4f4f5" />
</linearGradient>
<clipPath id="es-lens">
<circle cx="0" cy="0" r="31" />
</clipPath>
</defs>
{/* Soft ground shadow */}
<ellipse cx="130" cy="184" rx="74" ry="9" fill="#18181b" opacity="0.06" />
{/* Stack of empty result rows */}
<g>
<rect x="46" y="42" width="168" height="112" rx="14" fill="url(#es-card)" stroke="#e4e4e7" />
{[0, 1, 2].map((row) => (
<g key={row} opacity={1 - row * 0.22}>
<rect x={64} y={64 + row * 30} width={18} height={18} rx={5} fill="#e4e4e7" />
<rect x={92} y={68 + row * 30} width={94 - row * 22} height={7} rx={3.5} fill="#e4e4e7" />
<rect x={92} y={80 + row * 30} width={62 - row * 14} height={5} rx={2.5} fill="#f1f1f3" />
</g>
))}
</g>
{/* Magnifier sweeping across the empty rows */}
<motion.g
animate={float}
transition={still ? undefined : { duration: 4.2, ease: "easeInOut", repeat: Infinity }}
style={{ transformOrigin: "170px 118px" }}
>
<g transform="translate(170 118)">
<circle cx="0" cy="0" r="31" fill="#ffffff" fillOpacity="0.55" />
{/* Glass glare, clipped to the lens */}
<g clipPath="url(#es-lens)">
<path d="M-34 14 L-6 -34 L8 -34 L-20 14 Z" fill="#ffffff" opacity="0.9" />
<path d="M-16 20 L4 -20 L12 -20 L-8 20 Z" fill="#ffffff" opacity="0.55" />
</g>
<circle cx="0" cy="0" r="31" fill="none" stroke={accent} strokeWidth="6" />
<line
x1="22"
y1="22"
x2="42"
y2="42"
stroke={accent}
strokeWidth="8"
strokeLinecap="round"
/>
</g>
</motion.g>
{/* Little "nothing here" sparks */}
{!still &&
[
{ cx: 62, cy: 30, r: 3, delay: 0 },
{ cx: 214, cy: 58, r: 2.4, delay: 0.6 },
{ cx: 44, cy: 132, r: 2.2, delay: 1.2 },
].map((spark, i) => (
<motion.circle
key={i}
cx={spark.cx}
cy={spark.cy}
r={spark.r}
fill={accent}
initial={{ opacity: 0.15 }}
animate={{ opacity: [0.15, 0.6, 0.15], scale: [1, 1.25, 1] }}
transition={{ duration: 2.6, repeat: Infinity, delay: spark.delay, ease: "easeInOut" }}
style={{ transformOrigin: `${spark.cx}px ${spark.cy}px` }}
/>
))}
</svg>
{title && (
<span style={{ fontSize: 16, fontWeight: 600, letterSpacing: "-0.01em" }}>{title}</span>
)}
{description && (
<span style={{ fontSize: 13.5, color: "#71717a", maxWidth: 280, lineHeight: 1.55 }}>
{description}
</span>
)}
</div>
);
}
A zero-state illustration for search: a magnifier drifting over a card of blank result rows, with a headline and a supporting line underneath. It is inline SVG with a single accent colour, so it inherits the page font, scales to any size without an asset, and needs no image request. The float and the sparks can be switched off with one prop.
Installation
Copy the source from the Code tab above into your project as EmptySearch.tsx.
The artwork is inline SVG and the two lines of copy are styled inline, so there
is no image to host and no stylesheet to import.
It imports framer-motion for the float and the pulsing sparks:
npm install framer-motionIf you only ever render it with still, you can drop that import and change
the two motion elements back to plain SVG elements, leaving a file with no
dependencies beyond React.
Usage
Every prop is optional. size is the rendered width in pixels and the height
follows at 78 percent of it:
import EmptySearch from "./components/EmptySearch";
export default function Example() {
return (
<EmptySearch
size={260}
accent="#7c3aed"
title="No results found"
description="Try a different search term or clear your filters."
/>
);
}Pass title={null} and description={null} for the artwork on its own, and
still to freeze it.
When to reach for it
A results list that came back empty, where the next step is changing the query rather than creating something. The default copy points at the search term and the filters for exactly that reason.
A filtered table or directory with no matches, sitting in the space the rows would occupy.
A command palette or in-app search panel at a smaller size, where the artwork
has to fit a dropdown rather than a page.
When not to
If nothing exists yet. A first-run empty state should point at the action that fills it, not at a search that found nothing. Different illustration, different copy.
If something went wrong. A failed request is not an empty result, and telling someone to change their search term when the server is down sends them in the wrong direction. Reach for Error 404 or a plain error message instead.
If it needs to be the page's heading. The title renders as a styled
<span>, not an <h1> or <h2>. That is deliberate, so the illustration can
sit inside a page that already has a heading, but it means you supply your own
if this is the main content.
Accessibility
The SVG has role="img" and a fixed label describing it as having no search
results, so the whole drawing is announced as one thing and none of its parts
leak into the reading order. The title and description below it are ordinary
text and are read normally.
Because the label is fixed in the source, translating the copy or changing
title does not change it. Update the aria-label in the copied file when you
change the language of the page.
The animation does not check prefers-reduced-motion. The magnifier floats and
the sparks pulse on an infinite loop until you pass still. Read the media
query yourself and pass still from it if you support that setting.
Notes
The <defs> block declares the ids es-card and es-lens. Rendering two of
these on one page duplicates those ids, so give them a unique suffix in the
copied file if that ever happens.
still removes the sparks entirely rather than just stopping them, so the
still version is slightly plainer than a paused frame of the animated one.
Colours other than the accent are fixed greys drawn to sit on a light background. On a dark surface the result card and its rows will need recolouring in the copied file.