One line, one reason, one action, with an icon that draws itself in.
Loading & progressmotion
01Preview
No projects yet
A project holds your deploys, domains and environment variables.
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion03Usage
import { EmptyState } from "@/components/ui/empty-state";
<EmptyState
variant="first-use"
heading="No projects yet"
description="A project holds your deploys, domains and environment variables."
>
<Button onClick={createProject}>Create project</Button>
</EmptyState>
<EmptyState variant="no-results" size="sm" heading={<>No files match “{query}”</>}>
<Button variant="secondary" onClick={clearFilters}>Clear filters</Button>
</EmptyState>04Source
"use client";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";
export type EmptyStateVariant = "default" | "first-use" | "no-results" | "inbox-zero";
export type EmptyStateProps = Omit<React.ComponentProps<"div">, "title"> & {
/**
* default: something could be here. first-use: nothing has been made yet (dashed edge, a place to fill).
* no-results: a search or filter came back empty. inbox-zero: everything is done, and that is good news.
*/
variant?: EmptyStateVariant;
/** One line saying what would be here: “No projects yet”. */
heading: React.ReactNode;
/** One short line on why it matters or how it fills. */
description?: React.ReactNode;
/** Replaces the variant's drawn icon. 20px, currentColor. Pass null for none. */
icon?: React.ReactNode;
/** The action that fills it, usually one primary button, optionally a quieter second. */
children?: React.ReactNode;
/** sm sits inside a table, menu or popover; md fills a region. */
size?: "sm" | "md";
headingLevel?: 2 | 3 | 4;
};
export function EmptyState({
variant = "default",
heading,
description,
icon,
children,
size = "md",
headingLevel = 3,
className,
...rest
}: EmptyStateProps) {
const reduce = useReducedMotion();
const H = `h${headingLevel}` as const;
const sm = size === "sm";
// A quiet arrival: the tile settles first, then the words, then the action.
// Only on mount; it never replays on re-render or filter change. The starting
// pose is the same with reduced motion (so server and client HTML match); only
// the opacity is animated then, and the travel and blur resolve at once.
const still = { y: { duration: 0 }, scale: { duration: 0 }, filter: { duration: 0 } };
const rise = (i: number) => ({
initial: { opacity: 0, y: 6, filter: "blur(2px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
transition: reduce ? { duration: 0.2, delay: i * 0.03, ...still } : { duration: 0.42, ease: ease.out, delay: 0.06 + i * 0.05 },
});
return (
<div
role="status"
data-variant={variant}
data-size={size}
className={cn(
"flex w-full flex-col items-center justify-center text-center",
sm ? "gap-3 px-4 py-6" : "gap-4 px-6 py-10",
variant === "first-use" && "rounded-xl border border-dashed border-line-2",
className,
)}
{...rest}
>
{icon !== null && (
<motion.div
aria-hidden
initial={{ opacity: 0, y: 6, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={reduce ? { duration: 0.2, ...still } : { duration: 0.46, ease: ease.out }}
className={cn(
"relative grid shrink-0 place-items-center border bg-raised shadow-[var(--shadow)]",
sm ? "size-8 rounded-lg [&_svg]:size-4" : "size-10 rounded-xl [&_svg]:size-5",
variant === "inbox-zero" ? "border-success/25 text-success" : "border-line-2 text-fg-2",
)}
>
{icon ?? <Glyph variant={variant} reduce={!!reduce} />}
</motion.div>
)}
<div className={cn("flex max-w-[34ch] flex-col", sm ? "gap-0.5" : "gap-1")}>
<motion.div {...rise(1)}>
<H className={cn("text-balance font-medium text-fg", sm ? "text-[13px] tracking-[-0.006em]" : "text-[14.5px] tracking-[-0.015em]")}>{heading}</H>
</motion.div>
{description != null && (
<motion.p {...rise(2)} className={cn("text-pretty text-fg-3", sm ? "text-[12px] leading-[17px]" : "text-[12.5px] leading-[19px]")}>
{description}
</motion.p>
)}
</div>
{children != null && (
<motion.div {...rise(3)} className={cn("flex flex-wrap items-center justify-center gap-2", !sm && "pt-1")}>
{children}
</motion.div>
)}
</div>
);
}
// Each variant's icon draws itself in as the tile lands: stroke first, detail last.
function Glyph({ variant, reduce }: { variant: EmptyStateVariant; reduce: boolean }) {
// Same starting pose either way so the server HTML matches; reduced motion just skips to the end.
const draw = (delay: number, duration = 0.4) => ({
initial: { pathLength: 0 },
animate: { pathLength: 1 },
transition: reduce ? { duration: 0 } : { duration, ease: ease.out, delay },
});
const props = { width: 20, height: 20, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.25, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
if (variant === "first-use")
return (
<svg {...props}>
<motion.rect x="2.75" y="2.75" width="10.5" height="10.5" rx="2.25" {...draw(0.12, 0.5)} />
<motion.path d="M8 5.5v5M5.5 8h5" {...draw(0.4, 0.26)} />
</svg>
);
if (variant === "no-results")
return (
<svg {...props}>
<motion.circle cx="7" cy="7" r="4.25" {...draw(0.12, 0.5)} />
<motion.path d="m10.25 10.25 3 3" {...draw(0.46, 0.2)} />
<motion.path d="M5.6 7h2.8" {...draw(0.58, 0.18)} />
</svg>
);
if (variant === "inbox-zero")
return (
<svg {...props}>
<motion.circle cx="8" cy="8" r="5.75" {...draw(0.1, 0.5)} />
<motion.path d="m5.5 8.25 1.75 1.75 3.25-3.75" {...draw(0.42, 0.32)} />
</svg>
);
return (
<svg {...props}>
<motion.path d="M2.5 9.5 4 3.75h8L13.5 9.5v3a.75.75 0 0 1-.75.75H3.25a.75.75 0 0 1-.75-.75z" {...draw(0.12, 0.55)} />
<motion.path d="M2.5 9.5h3l.75 1.5h3.5l.75-1.5h3" {...draw(0.42, 0.3)} />
</svg>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| heading* | ReactNode | — | One line saying what would be here. Balanced if it wraps. |
| description | ReactNode | — | One short line on why it matters or how it fills. |
| variant | "default" | "first-use" | "no-results" | "inbox-zero" | "default" | Sets the drawn icon. first-use adds a dashed edge: a place waiting to be filled. inbox-zero tints the tile as good news. |
| children | ReactNode | — | The action that fills it: one primary button, optionally a quieter second. |
| icon | ReactNode | — | Replaces the variant’s icon (20px, currentColor). Pass null for no tile. |
| size | "sm" | "md" | "md" | sm sits inside a table, menu or popover; md fills a region. |
| headingLevel | 2 | 3 | 4 | 3 | The heading element, so it fits the page outline. |
06Notes
Behavior
- Lives inside the region it describes, at the region’s size: give it flex-1 or a height and it centers itself, never a page takeover.
- no-results is a different state from first-use: say what was searched and offer Clear filters, not Create.
- The entrance runs once on mount. Changing the heading as someone types updates it in place without replaying.
- Text is capped at 34ch and balanced, so long headings wrap into two even lines instead of an orphan.
Motion
- The tile rises 6px from 0.94 scale over 460ms on the expo ease-out; heading, description and actions follow 50ms apart, rising 6px from a 2px blur.
- The icon strokes draw in behind it (400–550ms), detail last: the plus, the magnifier’s handle, the tick.
- Reduced motion keeps a 200ms fade and resolves travel, blur and the drawing instantly, from the same starting pose so server and client HTML match.
Accessibility
- role="status", so a list that filters down to nothing is announced politely.
- The heading is a real heading (h3 by default); the icon tile is aria-hidden.
- When the action that removes the empty state is pressed, send focus somewhere that still exists (the demo returns it to the search field).