Feedback

Empty State

Icon in soft concentric rings with title, description, and primary action, entering with an 80ms blur-and-rise stagger.

Install

npx shadcn@latest add @paragon/empty-state

Also installs: button

empty-state.tsx

"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>
  );
}