Skip to main content

UX Standards — 06: Stats Summary Cards

Governs: Stat/summary cards that appear above the data table on list pages. Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first.


Visual Anatomy

┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Total Faxes │ │ Pending Review │ │ Completed Today │ │ Failed │
│ 1,247 [Icon] │ │ 84 [Icon] │ │ 203 [Icon] │ │ 12 [Icon] │
│ All time │ │ Needs attention │ │ Since midnight │ │ Requires action │
└──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘

StatCard Component

// From src/components/doctype-list-page.tsx
interface StatCardConfig {
title: string;
value: string | number;
description?: string;
icon?: React.ComponentType<{ className?: string }>;
variant?: 'default' | 'green' | 'blue' | 'yellow' | 'red';
onClick?: () => void;
}

function StatCard({ title, value, description, icon: Icon, variant = 'default', onClick }: StatCardConfig) {
const variantClasses = {
default: 'text-foreground',
green: 'text-primary',
blue: 'text-blue-600 dark:text-blue-400',
yellow: 'text-yellow-600 dark:text-yellow-400',
red: 'text-destructive',
};

return (
<div
className={`rounded-lg border bg-card p-4 ${onClick ? 'cursor-pointer hover:bg-accent/50 transition-colors' : ''}`}
onClick={onClick}
>
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
{Icon && <Icon className={`h-5 w-5 ${variantClasses[variant]}`} />}
</div>
<div className="mt-2">
<p className={`text-2xl font-bold ${variantClasses[variant]}`}>{value}</p>
{description && (
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
)}
</div>
</div>
);
}

Card Anatomy

┌─────────────────────────────────────────┐
│ rounded-lg border bg-card p-4 │
│ │
│ Title (text-sm font-medium muted) Icon │
│ │
│ 1,247 ← text-2xl font-bold │
│ All time ← text-xs muted │
└─────────────────────────────────────────┘
ElementSpec
Containerrounded-lg border bg-card p-4
Titletext-sm font-medium text-muted-foreground
Iconh-5 w-5 — color from variant
Valuetext-2xl font-bold — color from variant
Descriptiontext-xs text-muted-foreground mt-1
Hover (clickable)cursor-pointer hover:bg-accent/50 transition-colors

Color Variants

VariantIcon & Value ColorUse For
defaulttext-foregroundNeutral counts (total, all)
greentext-primarySuccess, completed, active
bluetext-blue-600 dark:text-blue-400In-progress, informational
yellowtext-yellow-600 dark:text-yellow-400Warning, pending, attention needed
redtext-destructiveErrors, failures, critical counts

Note: blue and yellow still use Tailwind color classes here because no CSS var exists for these colors. Use dark: variants to support dark mode.


Grid Layout Rules

The grid auto-sizes based on how many cards are defined:

function getGridCols(count: number): string {
if (count <= 2) return 'grid-cols-1 md:grid-cols-2';
if (count === 3) return 'grid-cols-1 md:grid-cols-3';
if (count === 4) return 'grid-cols-2 md:grid-cols-4';
if (count === 5) return 'grid-cols-2 md:grid-cols-5';
return 'grid-cols-2 md:grid-cols-6'; // 6+
}

// Usage in DocTypeListPage:
{stats && (
<div className={`grid gap-4 ${getGridCols(stats.length)}`}>
{stats.map((stat) => <StatCard key={stat.title} {...stat} />)}
</div>
)}
Card CountMobileDesktop
1–21 column2 columns
31 column3 columns
42 columns4 columns
52 columns5 columns
6+2 columns6 columns

Gap between cards: gap-4 (16px).


Positioning

Stats cards always appear as the first element in the content stack, before tabs and before the toolbar:

<div className="flex h-full flex-col space-y-4">
{/* 1. Stats cards — FIRST */}
{stats && (
<div className={`grid gap-4 ${getGridCols(stats.length)}`}>
{stats.map(stat => <StatCard key={stat.title} {...stat} />)}
</div>
)}

{/* 2. Tab navigation — SECOND (optional) */}
{preToolbarContent}

{/* 3. Toolbar — THIRD */}
<DataTableToolbar ... />

{/* 4. Table — FOURTH */}
<div className="flex min-h-0 flex-1 flex-col">
<DataTableSortable ... />
</div>
</div>

DocTypeListPage Integration

Pass stats as an array to DocTypeListPage:

<DocTypeListPage
table={table}
stats={[
{
title: 'Total Faxes',
value: data.totalCount.toLocaleString(),
description: 'All time',
icon: FileText,
variant: 'default',
},
{
title: 'Pending Review',
value: data.pendingCount,
description: 'Needs attention',
icon: Clock,
variant: 'yellow',
onClick: () => applyFilter('status', 'pending'), // optional
},
{
title: 'Completed Today',
value: data.completedToday,
description: 'Since midnight',
icon: CheckCircle,
variant: 'green',
},
{
title: 'Failed',
value: data.failedCount,
description: 'Requires action',
icon: AlertCircle,
variant: 'red',
},
]}
...
/>

Loading State

While data loads, render skeleton cards matching the expected count:

{isLoading ? (
<div className={`grid gap-4 ${getGridCols(4)}`}>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card p-4 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-16" />
<Skeleton className="h-3 w-20" />
</div>
))}
</div>
) : (
<div className={`grid gap-4 ${getGridCols(stats.length)}`}>
{stats.map(stat => <StatCard key={stat.title} {...stat} />)}
</div>
)}

Clickable Stats (Filter Shortcut)

When a stat card is clickable, it applies a filter to the table below:

{
title: 'Pending Review',
value: 84,
icon: Clock,
variant: 'yellow',
onClick: () => {
// Apply a column filter on the table
table.getColumn('status')?.setFilterValue('pending');
// Or set a URL search param for server-side filtering
router.push('?status=pending');
}
}

Clickable cards receive cursor-pointer hover:bg-accent/50 transition-colors. Non-clickable cards have no pointer or hover state.


Violation Checklist

  • Card container uses rounded-lg border bg-card p-4
  • Title uses text-sm font-medium text-muted-foreground
  • Icon uses h-5 w-5 (not h-4 or h-6)
  • Value uses text-2xl font-bold
  • Description uses text-xs text-muted-foreground mt-1
  • Grid uses gap-4 with correct grid-cols-* count
  • Stats appear BEFORE tabs and BEFORE toolbar
  • Color variants use text-primary, text-destructive, or specified dark/light paired classes
  • Clickable cards have cursor-pointer hover:bg-accent/50 transition-colors
  • Loading state shows skeleton cards of same count