Filtered to Nothing
The reset action slides in beside the chips, and the chips give one small nudge to name the cause.
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 { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Filtered to Nothing
*
* An empty result set caused by filters is not the same as an empty
* database, and the motion says so: the reset action slides in beside
* the chips that caused it, then those chips give one small nudge — a
* pointer at the cause, not a shake. Clearing them drops the chips and
* brings the rows back in the same frame.
*
* 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 FilteredRow = {
title: string;
meta: string;
};
export type FilteredToNothingProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Active filters, in the order they were applied. */
filters?: string[];
/** Headline of the empty block. */
title?: string;
/** One line naming the cause. */
message?: string;
/** Label of the reset action. */
clearLabel?: string;
/** Rows that come back once the filters are cleared. */
rows?: FilteredRow[];
/** Fires when the filters are cleared. */
onClear?: () => void;
/** Block width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** px the empty block travels on its way in. */
rise: number;
/** px the reset action slides in from. */
slide: number;
/** px of the single nudge the chips give. */
nudge: number;
/** Seconds that nudge takes, out and back. */
nudgeSeconds: number;
fadeSeconds: number;
/** Seconds between rows returning. */
stagger: number;
markSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the chips carry words, so their nudge is a two-pixel
// tween out and back — never a spring, never a shake. The only spring in
// the file settles the drawn mark, above 0.87 damping ratio
// (damping / 2√stiffness) in every variant.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// For a filter bar someone is actively adjusting; the nudge is almost
// subliminal.
subtle: {
rise: 5,
slide: 5,
nudge: 1.5,
nudgeSeconds: 0.34,
fadeSeconds: 0.22,
stagger: 0.035,
markSpring: { type: "spring", stiffness: 440, damping: 42 },
},
// Clear enough to follow the pointer from the message to the chips.
default: {
rise: 8,
slide: 8,
nudge: 2.5,
nudgeSeconds: 0.42,
fadeSeconds: 0.28,
stagger: 0.055,
markSpring: { type: "spring", stiffness: 360, damping: 36 },
},
// A slower pointer, for a catalog where the filter bar is far from
// the results.
playful: {
rise: 12,
slide: 12,
nudge: 3.5,
nudgeSeconds: 0.5,
fadeSeconds: 0.32,
stagger: 0.075,
markSpring: { type: "spring", stiffness: 300, damping: 31 },
},
};
const FILTERS = ["In stock", "Under $50", "Ships today"];
const ROWS: FilteredRow[] = [
{ title: "Aluminium desk lamp", meta: "$64 · ships in 3 days" },
{ title: "Linen storage box", meta: "$38 · backordered" },
{ title: "Ceramic mug set", meta: "$42 · ships today" },
];
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` keeps chips, rules and the mark correct on light and
* dark pages alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function FilteredToNothing({
variant = "default",
filters = FILTERS,
title = "No items match these filters",
message = "Three filters are narrowing this list.",
clearLabel = "Clear all",
rows = ROWS,
onClear,
width = 320,
}: FilteredToNothingProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [cleared, setCleared] = useState(false);
const rise = reduceMotion ? 0 : cfg.rise;
const fade = { duration: cfg.fadeSeconds, ease: "easeOut" as const };
// Reduced motion drops the nudge entirely rather than shortening it:
// a positional hint is exactly the kind of movement the setting is
// asking to be spared, and the sentence already names the cause.
const nudgeAnimation = reduceMotion
? { y: 0 }
: { y: [0, -cfg.nudge, 0] };
const clear = () => {
setCleared(true);
onClear?.();
};
return (
<div style={{ width, boxSizing: "border-box" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "10px 14px",
borderBottom: `1px solid ${tone(9)}`,
minHeight: 26,
}}
>
<AnimatePresence initial={false}>
{!cleared &&
filters.map((filter, index) => (
<motion.span
key={filter}
initial={false}
animate={nudgeAnimation}
// The nudge is deliberately late — it lands after the
// message has been read. The exit carries its own
// transition so leaving is immediate rather than
// inheriting that delay.
exit={{
opacity: 0,
y: reduceMotion ? 0 : -4,
transition: { duration: 0.16, ease: "easeOut" },
}}
transition={{
duration: cfg.nudgeSeconds,
ease: "easeInOut",
delay: 0.5 + index * 0.04,
}}
style={{
fontSize: 11,
padding: "4px 9px",
borderRadius: 999,
whiteSpace: "nowrap",
background: tone(8),
border: `1px solid ${tone(14)}`,
}}
>
{filter}
</motion.span>
))}
</AnimatePresence>
{/* Rendered from the first frame so its arrival cannot reflow the
chip row — only its opacity and offset change. */}
<motion.button
type="button"
onClick={clear}
initial={{ opacity: 0, x: reduceMotion ? 0 : cfg.slide }}
animate={{ opacity: cleared ? 0 : 0.62, x: 0 }}
transition={{ ...fade, delay: cleared ? 0 : 0.34 }}
style={{
font: "inherit",
marginLeft: "auto",
padding: 0,
fontSize: 11.5,
color: "inherit",
background: "none",
border: "none",
textDecoration: "underline",
textUnderlineOffset: 3,
whiteSpace: "nowrap",
cursor: "pointer",
pointerEvents: cleared ? "none" : "auto",
}}
>
{clearLabel}
</motion.button>
</div>
{/* Empty block and restored rows share one grid cell, so the frame
is sized by the taller of the two before either is shown. */}
<div style={{ display: "grid" }}>
<motion.div
aria-hidden={cleared}
animate={{ opacity: cleared ? 0 : 1 }}
transition={fade}
style={{
gridArea: "1 / 1",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
padding: "26px 20px",
pointerEvents: "none",
}}
>
<motion.div
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.93 }}
animate={{ opacity: 1, scale: 1 }}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: { ...cfg.markSpring, opacity: fade }
}
style={{ lineHeight: 0, marginBottom: 12 }}
>
<FunnelMark />
</motion.div>
<motion.div
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...fade, delay: 0.08 }}
style={{ fontSize: 14, fontWeight: 640 }}
>
{title}
</motion.div>
<motion.div
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 0.55, y: 0 }}
transition={{ ...fade, delay: 0.14 }}
style={{ fontSize: 12, marginTop: 5 }}
>
{message}
</motion.div>
</motion.div>
<div
aria-hidden={!cleared}
style={{
gridArea: "1 / 1",
alignSelf: "center",
padding: "4px 14px",
pointerEvents: cleared ? "auto" : "none",
}}
>
{rows.map((row, index) => (
<motion.div
key={row.title}
initial={false}
animate={{ opacity: cleared ? 1 : 0, y: cleared ? 0 : rise }}
transition={{ ...fade, delay: cleared ? index * cfg.stagger : 0 }}
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 12,
padding: "10px 2px",
borderTop: index === 0 ? "none" : `1px solid ${tone(8)}`,
}}
>
<span style={{ fontSize: 12.5, fontWeight: 600 }}>{row.title}</span>
<span style={{ fontSize: 11, opacity: 0.5, whiteSpace: "nowrap" }}>
{row.meta}
</span>
</motion.div>
))}
</div>
</div>
</div>
);
}
/** Line art authored inline: a funnel, stroked in `currentColor` at low
* opacity so it needs no asset and inherits the page theme. */
function FunnelMark() {
return (
<svg width="46" height="46" viewBox="0 0 48 48" fill="none" aria-hidden>
<path
d="M9 12h30L27.5 26v13l-7 4V26z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
opacity="0.32"
/>
{/* Nothing coming out the bottom: the one detail that makes this a
filtered-out mark rather than a filter icon. */}
<path
d="M22 44.5h4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
opacity="0.16"
/>
</svg>
);
}About this pattern
A list emptied by filters looks identical to a list with no data in it, and the difference decides what the reader does next. So the motion connects the two: the empty block settles in, the reset action slides into the chip row rather than under the message, and the chips themselves lift two pixels and come back once — a pointer at the cause, not a shake at the reader. Clearing drops the chips out and brings the rows back inside the same frame, which keeps the whole exchange in one place. Reduced motion drops the nudge outright; the sentence already names the cause.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Issue tracker
An over-filtered issue view offers a clear action next to the active filter pills.
Related patterns
- No Results SuggestAn empty result set settles into a quiet mark, then offers queries worth trying instead.
- All Caught UpA bell settles, a small badge completes its mark in one stroke, and the list confirms you are current.
- Archive EmptyOne fade and a lid settling three pixels onto a box — the least motion a state can have and still arrive.