No Results Suggest
An empty result set settles into a quiet mark, then offers queries worth trying instead.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · No Results Suggest
*
* An empty result set is a dead end unless it offers a way out. The mark
* settles once, the sentence naming the query follows it, and the queries
* worth trying instead arrive last — so the eye lands on the explanation
* before it lands on the options.
*
* Self-contained: depends only on `react` and `motion`. Neutrals are
* mixed from the inherited text color, so it reads on light and dark
* pages alike. Works with zero props.
* Requires the automatic JSX runtime (default since React 17).
*/
export type NoResultsSuggestProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The query that returned nothing. Shown back to the reader. */
query?: string;
/** Headline above the query line. */
headline?: string;
/** Queries offered instead. Three or four reads best. */
suggestions?: string[];
/** Label of the reset action under the suggestions. */
clearLabel?: string;
/** Fires with the suggestion that was picked. */
onSelect?: (suggestion: string) => void;
/** Fires when the reset action is pressed. */
onClear?: () => void;
/** Block width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** px each element travels on its way in. */
rise: number;
/** Seconds between one suggestion arriving and the next. */
stagger: number;
/** Seconds a text element takes to fade in. */
fadeSeconds: number;
/** The one spring in the file: the mark coming to rest. */
markSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: only the drawn mark springs, and it springs above 0.85
// damping ratio (damping / 2√stiffness) so it lands with a single soft
// settle. Everything with words in it travels on a tween and never
// scales. Variants change pace and distance, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Nearly a straight fade. For a results pane that empties and refills
// as someone types.
subtle: {
rise: 5,
stagger: 0.04,
fadeSeconds: 0.22,
markSpring: { type: "spring", stiffness: 420, damping: 42 },
},
// A readable sequence: mark, explanation, options. All-purpose.
default: {
rise: 8,
stagger: 0.06,
fadeSeconds: 0.28,
markSpring: { type: "spring", stiffness: 340, damping: 34 },
},
// Slower and further, for a full-page result view where the empty
// state is the only thing on screen.
playful: {
rise: 12,
stagger: 0.08,
fadeSeconds: 0.34,
markSpring: { type: "spring", stiffness: 280, damping: 29 },
},
};
const SUGGESTIONS = ["revenue forecast", "Q4 planning", "board deck"];
/** Theme-adaptive neutral: `currentColor` is the inherited text color, so
* mixing it with `transparent` gives chips, borders and a line-art mark
* that are correctly toned on light and dark pages alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function NoResultsSuggest({
variant = "default",
query = "quarterly forcast",
headline = "No matches",
suggestions = SUGGESTIONS,
clearLabel = "Clear search",
onSelect,
onClear,
width = 320,
}: NoResultsSuggestProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Reduced motion keeps the order — mark, sentence, options — and drops
// only the travel. The sequence is the information here.
const rise = reduceMotion ? 0 : cfg.rise;
const fade = (delay: number) => ({
duration: cfg.fadeSeconds,
ease: "easeOut" as const,
delay,
});
return (
<div
style={{
width,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
padding: "26px 20px 22px",
}}
>
<motion.div
aria-hidden
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.92 }}
animate={{ opacity: 1, scale: 1 }}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: { ...cfg.markSpring, opacity: { duration: cfg.fadeSeconds } }
}
style={{ lineHeight: 0, marginBottom: 14 }}
>
<SearchMark />
</motion.div>
<motion.div
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={fade(0.07)}
style={{ fontSize: 15, fontWeight: 640 }}
>
{headline}
</motion.div>
{/* The query is echoed back so the reader can see the typo they are
about to be offered a fix for. */}
<motion.div
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 0.58, y: 0 }}
transition={fade(0.12)}
style={{ fontSize: 12.5, marginTop: 5, lineHeight: 1.5 }}
>
Nothing came back for “{query}”
</motion.div>
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
gap: 7,
marginTop: 16,
}}
>
{suggestions.map((suggestion, index) => (
<motion.button
key={suggestion}
type="button"
onClick={() => onSelect?.(suggestion)}
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={fade(0.2 + index * cfg.stagger)}
style={{
font: "inherit",
fontSize: 12,
lineHeight: 1,
padding: "7px 11px",
borderRadius: 999,
color: "inherit",
background: tone(7),
border: `1px solid ${tone(13)}`,
cursor: "pointer",
}}
>
{suggestion}
</motion.button>
))}
</div>
<motion.button
type="button"
onClick={() => onClear?.()}
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 0.55, y: 0 }}
transition={fade(0.2 + suggestions.length * cfg.stagger + 0.04)}
style={{
font: "inherit",
fontSize: 12,
marginTop: 14,
padding: 0,
color: "inherit",
background: "none",
border: "none",
textDecoration: "underline",
textUnderlineOffset: 3,
cursor: "pointer",
}}
>
{clearLabel}
</motion.button>
</div>
);
}
/** Line art authored inline: stroked in `currentColor` at low opacity, so
* it needs no asset and inherits the page's theme for free. */
function SearchMark() {
return (
<svg width="52" height="52" viewBox="0 0 52 52" fill="none" aria-hidden>
<g
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
opacity="0.34"
>
<circle cx="23" cy="23" r="13" />
<line x1="32.6" y1="32.6" x2="41" y2="41" />
</g>
{/* The empty interior is the point: one short rule where a result
would have been. */}
<line
x1="18"
y1="23"
x2="28"
y2="23"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
opacity="0.18"
/>
</svg>
);
}About this pattern
The most common empty state in any product, and the one most often left as a bare sentence. A line-art mark settles once, the query that returned nothing is echoed back underneath it, and the queries worth trying instead arrive last in a short cascade — order that puts the explanation before the options, so the reader understands the dead end before being handed a way out of it. Nothing loops and nothing bounces: the block has to be readable while someone is still typing in the field above it. Reduced motion keeps the same order and drops only the travel.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Search results
The empty result set is replaced by a shorter query the reader can take.
Related patterns
- Empty CartThe cart mark drops and settles once, then recently viewed items slide in as the way back to shopping.
- Error RecoveryA failed panel settles without alarm, and the retry glyph turns exactly once per attempt.
- All Caught UpA bell settles, a small badge completes its mark in one stroke, and the list confirms you are current.