Skip to main content

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 SkeletonUse 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 loadingBackground sync / polling
Card/panel loadingProgress 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)
  • ErrorState has retry button when retrying is possible
  • Empty state icon is h-10 w-10 or h-12 w-12 text-muted-foreground
  • Empty state text always uses t() keys — no hardcoded English
  • LoadingState shared component used — not ad-hoc spinner div