UX Standards — 28: Loading and Empty States
Governs: All loading feedback and zero-data states across every page and component.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.
Loading: Skeleton vs Spinner
| Use Skeleton | Use Spinner |
|---|---|
| Content shape is known (table, cards, detail page) | Indeterminate operation (submitting a form, deleting, uploading) |
| Page-level data fetching (initial load) | In-button loading state during mutations |
| List of items loading | Background sync / polling |
| Card/panel loading | Progress within a wizard step |
Rule: Prefer Skeleton for page loads. Spinner is for actions. Never show a blank white area.
Skeleton Patterns
Table Skeleton
function TableSkeleton({ rows = 8 }: { rows?: number }) {
return (
<div className="space-y-2">
{/* Toolbar skeleton */}
<div className="flex items-center gap-2 py-2">
<Skeleton className="h-8 w-48" /> {/* search input */}
<Skeleton className="h-8 w-8" /> {/* filter button */}
<div className="ml-auto flex gap-2">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
</div>
</div>
{/* Header row */}
<div className="flex gap-4 px-4 py-2 border-b border-border">
{[20, 30, 25, 15, 10].map((w, i) => (
<Skeleton key={i} className="h-3" style={{ width: `${w}%` }} />
))}
</div>
{/* Data rows */}
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4 px-4 py-3 border-b border-border">
{[20, 30, 25, 15, 10].map((w, j) => (
<Skeleton key={j} className="h-4" style={{ width: `${w - (j % 3) * 3}%` }} />
))}
</div>
))}
</div>
);
}
Stat Cards Skeleton
function StatCardsSkeleton() {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="pt-6">
<div className="flex justify-between items-start">
<div className="space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-7 w-12" />
</div>
<Skeleton className="h-8 w-8 rounded-md" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
Detail Page Skeleton
function DetailPageSkeleton() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-start">
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-7 w-56" />
<Skeleton className="h-3 w-40" />
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-16" />
<Skeleton className="h-9 w-9" />
</div>
</div>
{/* Body */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card>
<CardContent className="pt-6">
<div className="grid grid-cols-2 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-full" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
<div>
<Card>
<CardContent className="pt-6 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-32" />
</div>
))}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
LoadingState Shared Component
// packages/shared/components/shared/loading-state.tsx
interface LoadingStateProps {
type?: 'table' | 'cards' | 'detail' | 'spinner';
rows?: number;
message?: string;
}
export function LoadingState({ type = 'spinner', rows, message }: LoadingStateProps) {
if (type === 'table') return <TableSkeleton rows={rows} />;
if (type === 'cards') return <StatCardsSkeleton />;
if (type === 'detail') return <DetailPageSkeleton />;
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
{message && <p className="text-sm text-muted-foreground">{message}</p>}
</div>
);
}
Empty States
Three distinct types — each has different messaging and CTA.
Type 1: No Data Yet (Fresh / First Use)
Used when a collection has never had any items.
function NoDataEmptyState({ onCreateClick }: { onCreateClick: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center gap-4">
<div className="rounded-full bg-muted p-4">
<BookOpen className="h-12 w-12 text-muted-foreground" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-foreground">{t('booking.noBookingsYet')}</h3>
<p className="text-sm text-muted-foreground max-w-sm">
{t('booking.noBookingsDescription')}
</p>
</div>
<Button onClick={onCreateClick}>
<Plus className="h-4 w-4 mr-2" />
{t('booking.createFirst')}
</Button>
</div>
);
}
Type 2: No Results (Search / Filter)
Used when a search or filter returned nothing.
function NoResultsEmptyState({ onClearFilters }: { onClearFilters: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center gap-3">
<SearchX className="h-10 w-10 text-muted-foreground" />
<div className="space-y-1">
<h3 className="font-medium text-foreground">{t('common.noResults')}</h3>
<p className="text-sm text-muted-foreground">{t('common.noResultsDescription')}</p>
</div>
<Button variant="outline" size="sm" onClick={onClearFilters}>
<FilterX className="h-4 w-4 mr-2" />
{t('common.clearFilters')}
</Button>
</div>
);
}
Type 3: No Permission
Used when the user cannot access the data.
function NoPermissionEmptyState() {
return (
<div className="flex flex-col items-center justify-center py-16 text-center gap-3">
<div className="rounded-full bg-muted p-4">
<Lock className="h-10 w-10 text-muted-foreground" />
</div>
<div className="space-y-1">
<h3 className="font-medium text-foreground">{t('common.accessRestricted')}</h3>
<p className="text-sm text-muted-foreground max-w-sm">
{t('common.accessRestrictedDescription')}
</p>
</div>
{/* No CTA — user cannot do anything here */}
</div>
);
}
ErrorState Shared Component
// packages/shared/components/shared/error-state.tsx
interface ErrorStateProps {
error?: Error | string | null;
onRetry?: () => void;
inline?: boolean; // compact inline version vs full-page centered
}
export function ErrorState({ error, onRetry, inline = false }: ErrorStateProps) {
const message = typeof error === 'string' ? error : error?.message ?? t('common.unknownError');
if (inline) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
{message}
{onRetry && (
<Button variant="ghost" size="sm" onClick={onRetry}>
<RefreshCw className="h-3 w-3 mr-1" />
{t('common.retry')}
</Button>
)}
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col items-center justify-center py-16 text-center gap-4">
<div className="rounded-full bg-destructive/10 p-4">
<AlertCircle className="h-10 w-10 text-destructive" />
</div>
<div className="space-y-1">
<h3 className="font-medium text-foreground">{t('common.somethingWentWrong')}</h3>
<p className="text-sm text-muted-foreground max-w-sm">{message}</p>
</div>
{onRetry && (
<Button variant="outline" onClick={onRetry}>
<RefreshCw className="h-4 w-4 mr-2" />
{t('common.tryAgain')}
</Button>
)}
</div>
);
}
Usage Pattern in Pages
export default function BookingsPage() {
const { data, isLoading, isError, error, refetch } = useMyNotifications();
if (isLoading) return <LoadingState type="table" />;
if (isError) return <ErrorState error={error} onRetry={refetch} />;
if (!data?.length) return <NoDataEmptyState onCreateClick={handleCreate} />;
return <DataTable ... />;
}
Violation Checklist
- Never show blank white area — loading state always present during fetch
- Skeleton used for page-level loads; spinner only for actions
- Skeleton layout matches actual page structure (not generic bars)
- Three empty state types used correctly: no-data vs no-results vs no-permission
- No-data state has a primary CTA button to create the first item
- No-results state has a "clear filters" action
- No-permission state has NO CTA (user can't do anything)
-
ErrorStatehas retry button when retrying is possible - Empty state icon is
h-10 w-10orh-12 w-12 text-muted-foreground - Empty state text always uses
t()keys — no hardcoded English -
LoadingStateshared component used — not ad-hoc spinner div