Stat Tile
A KPI tile with a label, a large aligned value, a trend beside its baseline, and an icon square washed in the tile's intent colour. Shown as a linked 4-up grid, a compact strip, and one elevated highlight tile.
Live preview
The 4-up grid
Card with href: hover one, and the lift is the card itself signalling the link.<script lang="ts">
import {
ArrowDownIcon,
ArrowUpIcon,
BuildingIcon,
Card,
GaugeIcon,
type IconComponent,
ReceiptIcon,
WalletIcon
} from '@urbicon-ui/blocks';
type Intent = 'primary' | 'success' | 'warning' | 'danger' | 'neutral';
interface Stat {
label: string;
value: string;
/** Qualifies the value; a trending tile names its baseline here. */
description: string;
trend?: { direction: 'up' | 'down'; label: string };
icon: IconComponent;
intent: Intent;
href: string;
}
const stats: Stat[] = [
{
label: 'Revenue (month)',
value: '€42,150',
description: 'vs. last month',
trend: { direction: 'up', label: '+12.4%' },
icon: WalletIcon,
intent: 'success',
href: '#revenue'
},
{
label: 'Occupancy',
value: '89.6%',
description: 'vs. last quarter',
trend: { direction: 'down', label: '−2.3 pt' },
icon: GaugeIcon,
intent: 'danger',
href: '#occupancy'
},
{
label: 'Open invoices',
value: '5',
description: '2 overdue',
icon: ReceiptIcon,
intent: 'warning',
href: '#invoices'
},
{
label: 'Properties',
value: '12',
description: '3 in progress',
icon: BuildingIcon,
intent: 'neutral',
href: '#buildings'
}
];
// One wash per intent, as full class strings — Tailwind reads classes whole,
// so none are assembled from the intent value. warning draws its glyph in
// the emphasis step: its base hue is far lighter than success or danger
// (L 0.75 vs 0.5 in the foundation scale) and fades on its own subtle wash.
const ICON_TILE: Record<Intent, string> = {
primary: 'bg-primary-subtle text-primary',
success: 'bg-success-subtle text-success',
warning: 'bg-warning-subtle text-warning-emphasis',
danger: 'bg-danger-subtle text-danger',
neutral: 'bg-surface-subtle text-text-secondary'
};
</script>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{#each stats as stat (stat.label)}
{@const TrendIcon = stat.trend?.direction === 'up' ? ArrowUpIcon : ArrowDownIcon}
{@const Icon = stat.icon}
<!-- href renders the Card as an <a> with Card's own hover lift and focus
ring. tier="bridge": the contain default reads as a bare corner at
tile size. -->
<Card variant="elevated" tier="bridge" href={stat.href}>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-text-tertiary text-xs font-medium">{stat.label}</p>
<p class="text-text-primary mt-2 text-2xl font-semibold tabular-nums">{stat.value}</p>
<div class="mt-1 flex items-center gap-2">
<p class="text-text-tertiary text-xs">{stat.description}</p>
{#if stat.trend}
<span
class={[
'inline-flex items-center gap-0.5 text-xs font-medium tabular-nums',
stat.trend.direction === 'up' ? 'text-success' : 'text-danger'
]}
>
<TrendIcon class="h-3 w-3" />
{stat.trend.label}
</span>
{/if}
</div>
</div>
<span
class={[
'grid h-9 w-9 shrink-0 place-items-center rounded-bridge',
ICON_TILE[stat.intent]
]}
>
<Icon class="h-4 w-4" />
</span>
</div>
</Card>
{/each}
</div>The compact strip
Tenants
38
Apartments
42
Occupancy
90.5%
<script lang="ts">
import { Card } from '@urbicon-ui/blocks';
</script>
<!-- One quiet wash groups the numbers; in a sidebar foot or header row,
per-number cards would be noise. -->
<Card variant="quiet">
<div class="flex flex-wrap gap-6">
<div>
<p class="text-text-tertiary text-xs">Tenants</p>
<p class="text-text-primary text-lg font-semibold tabular-nums">38</p>
</div>
<div>
<p class="text-text-tertiary text-xs">Apartments</p>
<p class="text-text-primary text-lg font-semibold tabular-nums">42</p>
</div>
<div>
<p class="text-text-tertiary text-xs">Occupancy</p>
<p class="text-text-primary text-lg font-semibold tabular-nums">90.5%</p>
</div>
</div>
</Card>The highlight tile
padding stepped up to lg, the icon square scaled to match.Active users
1,284
+86 in the last 7 days
<script lang="ts">
import { Card, UsersIcon } from '@urbicon-ui/blocks';
</script>
<!-- One per page — elevation stops meaning anything when every tile has it. -->
<Card variant="elevated" padding="lg">
<div class="flex items-center gap-5">
<span class="bg-primary-subtle text-primary grid h-14 w-14 place-items-center rounded-bridge">
<UsersIcon class="h-7 w-7" />
</span>
<div>
<p class="text-text-tertiary text-sm font-medium">Active users</p>
<p class="text-text-primary text-4xl font-semibold tabular-nums">1,284</p>
<p class="text-success mt-1 text-sm font-medium">+86 in the last 7 days</p>
</div>
</div>
</Card>Two decisions
Two surfaces: lifted and quiet
The grid tiles are variant="elevated": each KPI
sits on its own raised card, equal to its three neighbours, and href renders the Card as an <a> whose hover lift signals the link. The
strip is variant="quiet", a recessed wash for
numbers that comment rather than compete; the highlight tile stands apart by format — padding="lg" and the largest figure — not by a
surface of its own. The small tiles add tier="bridge" because the default contain radius
reads as a bare corner at tile size. The full contract of cards that link (and why the href never moves to a wrapper) is Clickable Card.
Up is green only while up is good
Both trending tiles read the way their arrows point: revenue up is good, occupancy down is
not, so the arrow's direction may pick the colour. For a metric whose good direction is
down (consumption, churn, arrears) that ternary is wrong; derive the trend colour from the
same judgement that sets the tile's intent. And a delta needs its baseline named: +12.4% against nothing says nothing, so the description
line carries it ("vs. last month"); without one, drop the trend.
The four tiles are one loop over stats because they
differ only in data. The moment a second page needs one, move the tile markup into its own StatTile.svelte with Stat as its props; the grid stays in the page.