Empty, Loading & Error States
The four states every data-backed page needs — loading skeleton, error with retry, first-run empty state, and loaded content — in one interchangeable slot.
pnpm dlx shadcn@latest add @cosskit/empty-loading-errorRequires the @cosskit namespace in your components.json.
Code
"use client";
import {
BookOpenIcon,
FolderPlusIcon,
ImportIcon,
LifeBuoyIcon,
PlusIcon,
RefreshCwIcon,
TriangleAlertIcon,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardPanel,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs";
// ---------------------------------------------------------------------------
// The four states, each self-contained.
//
// In a real page you pick one — the Tabs switcher below exists only so the
// preview can show all four. Delete it and branch on your own data instead:
//
// if (isLoading) return <ProjectsLoading />;
// if (error) return <ProjectsError onRetry={refetch} />;
// if (!projects.length) return <ProjectsEmpty />;
// return <ProjectsList projects={projects} />;
// ---------------------------------------------------------------------------
/**
* Skeleton mirrors the real row layout — same heights, same columns, same
* count. A skeleton that does not match causes a visible reflow the moment
* data lands, which reads as jank even though nothing is broken.
*/
function ProjectsLoading() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
<Skeleton className="h-4 w-56" />
</CardHeader>
<CardPanel className="flex flex-col gap-4">
{[0, 1, 2].map((row) => (
<div key={row}>
<div className="flex items-center gap-4 py-1">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40 max-w-full" />
<Skeleton className="h-3 w-64 max-w-full" />
</div>
<Skeleton className="h-5 w-16 shrink-0 max-sm:hidden" />
<Skeleton className="h-4 w-20 shrink-0 max-md:hidden" />
</div>
{row < 2 && <Separator className="mt-4" />}
</div>
))}
</CardPanel>
</Card>
);
}
/**
* Error state. Always offer the action that resolves it — a dead end with no
* retry forces a full page reload, which is worse than the original failure.
*/
function ProjectsError() {
return (
<Card>
<CardPanel>
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<TriangleAlertIcon className="text-destructive" />
</EmptyMedia>
<EmptyTitle>Couldn't load projects</EmptyTitle>
<EmptyDescription>
The request timed out before we heard back. Your projects are safe
— this is a connection problem, not a data problem.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="flex flex-row flex-wrap items-center justify-center gap-2">
<Button size="sm">
<RefreshCwIcon />
Try again
</Button>
<Button size="sm" variant="outline">
<LifeBuoyIcon />
Contact support
</Button>
</EmptyContent>
<p className="text-muted-foreground/70 text-xs">
Reference: req_8f2c41ad — quote this if you contact support.
</p>
</Empty>
</CardPanel>
</Card>
);
}
/**
* First-run empty state — no data has ever existed. Distinct from a
* no-results state (see `table-filters`), which means filters excluded
* everything and should offer to clear them instead of to create.
*/
function ProjectsEmpty() {
return (
<Card>
<CardPanel>
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<FolderPlusIcon />
</EmptyMedia>
<EmptyTitle>No projects yet</EmptyTitle>
<EmptyDescription>
Projects group your work and control who can see it. Create one to
get started, or import an existing repository.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="flex flex-row flex-wrap items-center justify-center gap-2">
<Button size="sm">
<PlusIcon />
Create project
</Button>
<Button size="sm" variant="outline">
<ImportIcon />
Import repository
</Button>
</EmptyContent>
<Button
className="text-muted-foreground"
render={<a href="#" />}
size="sm"
variant="link"
>
<BookOpenIcon />
Read the projects guide
</Button>
</Empty>
</CardPanel>
</Card>
);
}
const projects = [
{
initials: "AR",
name: "Atlas Rebuild",
description: "Migration of the reporting pipeline onto the new warehouse",
status: "Active",
variant: "success" as const,
updated: "2h ago",
},
{
initials: "BF",
name: "Billing Refresh",
description: "Usage-based pricing, proration, and invoice redesign",
status: "In review",
variant: "info" as const,
updated: "Yesterday",
},
{
initials: "CP",
name: "Customer Portal",
description: "Self-serve seat management and SSO configuration",
status: "Paused",
variant: "secondary" as const,
updated: "6 days ago",
},
];
function ProjectsList() {
return (
<Card>
<CardHeader>
<CardTitle>Projects</CardTitle>
<CardDescription>3 active projects in this workspace.</CardDescription>
</CardHeader>
<CardPanel className="flex flex-col gap-4">
{projects.map((project, index) => (
<div key={project.name}>
<div className="flex items-center gap-4 py-1">
<Avatar className="size-9 shrink-0 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{project.initials}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-medium leading-none">
{project.name}
</span>
<span className="truncate text-muted-foreground text-xs leading-none">
{project.description}
</span>
</div>
<Badge className="max-sm:hidden" variant={project.variant}>
{project.status}
</Badge>
<span className="shrink-0 text-muted-foreground text-xs max-md:hidden">
{project.updated}
</span>
</div>
{index < projects.length - 1 && <Separator className="mt-4" />}
</div>
))}
</CardPanel>
</Card>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
const STATES = [
{ label: "Loading", value: "loading" },
{ label: "Error", value: "error" },
{ label: "Empty", value: "empty" },
{ label: "Loaded", value: "loaded" },
];
export default function EmptyLoadingError() {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6 md:p-8 [&_[data-slot=button]]:transition-[box-shadow,scale] [&_[data-slot=button]:not(.w-full)]:motion-safe:hover:scale-[1.05] [&_[data-slot=button].w-full]:motion-safe:hover:scale-[1.03]">
<div className="flex flex-col gap-1">
<h1 className="font-semibold text-2xl tracking-tight">Projects</h1>
<p className="text-muted-foreground text-sm">
Every data-backed page needs all four of these. Switch between them to
compare.
</p>
</div>
{/* Demo-only switcher. Delete this Tabs wrapper and branch on your own
loading/error/data values instead — see the note at the top. */}
<Tabs defaultValue="empty">
<TabsList className="w-full sm:w-auto sm:self-start">
{STATES.map((state) => (
<TabsTab key={state.value} value={state.value}>
{state.label}
</TabsTab>
))}
</TabsList>
<TabsPanel className="mt-4" value="loading">
<ProjectsLoading />
</TabsPanel>
<TabsPanel className="mt-4" value="error">
<ProjectsError />
</TabsPanel>
<TabsPanel className="mt-4" value="empty">
<ProjectsEmpty />
</TabsPanel>
<TabsPanel className="mt-4" value="loaded">
<ProjectsList />
</TabsPanel>
</Tabs>
</div>
);
}
Dependencies
- @coss/ui