Skip to main content
Urbicon UI
Back to Recipes

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.

Built with Card

Live preview

The 4-up grid

Each tile is a 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

Three bare label/value pairs on one quiet card, for a sidebar foot or header row where a grid of tiles would shout.

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

One promoted metric: an elevated card, 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.