Cosskit

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-error

Requires the @cosskit namespace in your components.json.

Open full page

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&apos;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