Design the non-happy states

Give empty, loading and error states a real design, not a blank.

From shadcn (Vercel plugin): Composition recipes and Anti-patterns.

One card, four states

Switch states; try Retry and the empty-state button.

Request state
Placeholder textBad
{loading && "Loading..."}
Card, Skeleton, AlertGood
<Skeleton /> · <Alert variant="destructive">
export function InvoicesCard({ state, invoices, onRetry, onCreate }) {
  return (
    <Card>
      <CardHeader><CardTitle>Invoices</CardTitle></CardHeader>
      <CardContent className="min-h-[234px]">
        {state === "loading" && <InvoiceRowsSkeleton rows={5} />}
        {state === "error" && (
          <Alert variant="destructive">
            <AlertCircle className="h-4 w-4" />
            <AlertTitle>Couldn't load invoices</AlertTitle>
            <AlertDescription>The request timed out. Nothing was changed.</AlertDescription>
            <Button size="sm" variant="outline" onClick={onRetry}>Retry</Button>
          </Alert>
        )}
        {state === "empty" && (
          <div className="flex flex-col items-center gap-2 text-center">
            <FileText className="h-5 w-5 text-muted-foreground" />
            <p className="font-medium">No invoices yet</p>
            <p className="text-sm text-muted-foreground">Invoices you send appear here.</p>
            <Button size="sm" onClick={onCreate}>Create invoice</Button>
          </div>
        )}
        {state === "success" && <InvoiceTable rows={invoices} />}
      </CardContent>
    </Card>
  );
}

Slow network, stable layout

Send a request and watch the section below each card.

Text, then contentBad
shift0px
Skeleton, final shapeGood
shift0px
function InvoiceRowsSkeleton({ rows = 5 }) {
  return (
    <Table aria-busy="true">
      <InvoiceTableHeader />
      <TableBody>
        {Array.from({ length: rows }, (_, i) => (
          <TableRow key={i} className="h-10">
            <TableCell><Skeleton className="h-3.5 w-3/4" /></TableCell>
            <TableCell><Skeleton className="h-5 w-12 rounded-full" /></TableCell>
            <TableCell><Skeleton className="ml-auto h-3.5 w-16" /></TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

Auth errors, inline

Type an email, then sign in on both sides.

Red line under buttonBad
<p className="text-red-500">{error}</p>
Card + inline AlertGood
<Alert variant="destructive">
<Card className="mx-auto w-full max-w-sm">
  <CardHeader>
    <CardTitle>Sign in</CardTitle>
    <CardDescription>Use your work email.</CardDescription>
  </CardHeader>
  <CardContent className="grid gap-4">
    <Button variant="outline">Continue with SSO</Button>
    <div className="flex items-center gap-2">
      <Separator className="flex-1" />
      <span className="text-xs text-muted-foreground">or</span>
      <Separator className="flex-1" />
    </div>
    {error && (
      <Alert variant="destructive">
        <AlertCircle className="h-4 w-4" />
        <AlertTitle>Wrong email or password</AlertTitle>
        <AlertDescription>Check both and try again.</AlertDescription>
      </Alert>
    )}
    <div className="grid gap-1.5">
      <Label htmlFor="email">Email</Label>
      <Input id="email" type="email" defaultValue={email} />
    </div>
    …
  </CardContent>
</Card>

Why it matters, pros and cons

Why it matters

A skeleton holds the layout; an empty card or Alert says what to do next.

Pros

  • No layout shift while loading.
  • Every state offers a next action.
  • Errors are announced and keep input.

Cons

  • A skeleton that drifts from the table shifts anyway.
  • Under ~150ms a skeleton only flashes; delay it.
  • Page-wide errors belong in a banner or toast.