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.
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
shiftSkeleton, final shapeGood
shiftfunction 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.