Icon in soft concentric rings with title, description, and primary action, entering with an 80ms blur-and-rise stagger.
npx shadcn@latest add @paragon/empty-stateAlso installs: button
"use client";
import * as React from "react";
import { Inbox } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
export interface EmptyStateProps
extends Omit<React.ComponentProps<"div">, "title"> {
/** Icon centered in the ring treatment. Defaults to an inbox. */
icon?: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
/** Renders a primary action button. */
actionLabel?: string;
onAction?: () => void;
/** Custom action slot; overrides actionLabel. */
action?: React.ReactNode;
}
/**
* Empty state: icon in soft concentric rings, title, description, primary
* action. Enters with a 3-group stagger (80ms) of blur + rise. Rare/first-run
* surface, so it gets the delight budget.
*/
export function EmptyState({
icon,
title,
description,
actionLabel,
onAction,
action,
className,
...props
}: EmptyStateProps) {
return (
<div
className={cn(
"flex w-full flex-col items-center px-6 py-12 text-center",
className,
)}
{...props}
>
<style href="paragon-empty-state" precedence="paragon">{`
@keyframes pg-empty-state-enter {
from { opacity: 0; transform: translateY(12px); filter: blur(4px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
[data-empty-state] > [data-stagger] {
animation: pg-empty-state-enter 300ms var(--ease-out) both;
}
[data-empty-state] > [data-stagger="1"] { animation-delay: 80ms; }
[data-empty-state] > [data-stagger="2"] { animation-delay: 160ms; }
@media (prefers-reduced-motion: reduce) {
[data-empty-state] > [data-stagger] { animation: none; }
}
`}</style>
<div
data-empty-state=""
className="flex w-full flex-col items-center"
>
<div data-stagger="0" className="relative flex items-center justify-center py-6">
{/* Soft concentric rings, outermost faintest. */}
<span
aria-hidden
className="pointer-events-none absolute size-28 rounded-full border border-border/40"
/>
<span
aria-hidden
className="pointer-events-none absolute size-20 rounded-full border border-border/70"
/>
<span className="relative flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground [&_svg]:size-5">
{icon ?? <Inbox aria-hidden />}
</span>
</div>
<div data-stagger="1" className="mt-4 max-w-sm">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
{description && (
<p className="mt-1.5 text-[13px] leading-5 text-muted-foreground">
{description}
</p>
)}
</div>
{(action || actionLabel) && (
<div data-stagger="2" className="mt-5">
{action ?? <Button onClick={onAction}>{actionLabel}</Button>}
</div>
)}
</div>
</div>
);
}