Dashboard stats are one of the most common admin panel patterns. Here's how to build them in Angular 22 with Bootstrap 5 โ€” from a single reusable stat card to a full dashboard layout with Chart.js.

Stat Card Component

// stat-card.component.ts
import { Component, Input, computed, signal } from '@angular/core'
import { NgClass, NgIf } from '@angular/common'

export interface StatData {
  title: string
  value: string | number
  change?: number         // percentage, e.g. 12.5 = +12.5%
  changeLabel?: string    // e.g. "vs last month"
  icon?: string           // emoji or icon class
  color?: string          // Bootstrap color: primary, success, danger, warning, info
}

@Component({
  selector: 'app-stat-card',
  standalone: true,
  imports: [NgClass, NgIf],
  template: `
    <div class="card border-0 shadow-sm h-100">
      <div class="card-body p-4">
        <div class="d-flex align-items-start justify-content-between mb-3">
          <!-- Icon -->
          <div
            class="rounded-2 d-flex align-items-center justify-content-center"
            style="width:48px;height:48px;font-size:1.4rem;"
            [style.background]="iconBg"
          >
            {{ stat.icon || '๐Ÿ“Š' }}
          </div>
          <!-- Change badge -->
          <span
            *ngIf="stat.change !== undefined"
            class="badge fw-semibold px-2 py-1"
            [ngClass]="{
              'text-success': stat.change > 0,
              'text-danger':  stat.change < 0,
              'text-muted':   stat.change === 0
            }"
            style="background:transparent;font-size:0.8rem;"
          >
            {{ stat.change > 0 ? 'โ†‘' : stat.change < 0 ? 'โ†“' : 'โ†’' }}
            {{ stat.change | number:'1.1-1' }}%
          </span>
        </div>

        <!-- Value -->
        <div class="fw-bold mb-1" style="font-size:1.75rem;line-height:1;">
          {{ stat.value }}
        </div>

        <!-- Title -->
        <div class="text-muted small">{{ stat.title }}</div>

        <!-- Change label -->
        <div *ngIf="stat.changeLabel" class="text-muted mt-1" style="font-size:0.72rem;">
          {{ stat.changeLabel }}
        </div>
      </div>
      <!-- Colored bottom border -->
      <div class="rounded-bottom" [style.background]="borderColor" style="height:3px;"></div>
    </div>
  `
})
export class StatCardComponent {
  @Input() stat!: StatData

  get iconBg(): string {
    const map: Record<string, string> = {
      primary: 'rgba(13,110,253,0.1)',
      success: 'rgba(25,135,84,0.1)',
      danger:  'rgba(220,53,69,0.1)',
      warning: 'rgba(255,193,7,0.1)',
      info:    'rgba(13,202,240,0.1)',
    }
    return this.stat.color ? map[this.stat.color] || 'rgba(253,71,102,0.1)' : 'rgba(253,71,102,0.1)'
  }

  get borderColor(): string {
    const map: Record<string, string> = {
      primary: '#0d6efd',
      success: '#198754',
      danger:  '#dc3545',
      warning: '#ffc107',
      info:    '#0dcaf0',
    }
    return this.stat.color ? map[this.stat.color] || '#fd4766' : '#fd4766'
  }
}

Stats Dashboard Page

// stats-dashboard.component.ts
import { Component, signal, computed, OnInit, AfterViewInit, ViewChild, ElementRef, OnDestroy } from '@angular/core'
import { NgFor, DecimalPipe, CurrencyPipe } from '@angular/common'
import { StatCardComponent, StatData } from './stat-card.component'
import { Chart } from 'chart.js/auto'

@Component({
  selector: 'app-stats-dashboard',
  standalone: true,
  imports: [NgFor, StatCardComponent, CurrencyPipe],
  template: `
    <div class="container-fluid py-4 px-4">

      <!-- Page header -->
      <div class="d-flex justify-content-between align-items-center mb-4">
        <div>
          <h4 class="fw-bold mb-0">Dashboard</h4>
          <p class="text-muted small mb-0">Welcome back, Gagan ๐Ÿ‘‹</p>
        </div>
        <div class="d-flex gap-2">
          <select class="form-select form-select-sm" style="width:auto;">
            <option>Last 30 days</option>
            <option>Last 7 days</option>
            <option>This year</option>
          </select>
          <button class="btn btn-sm text-white" style="background:#fd4766;">Export</button>
        </div>
      </div>

      <!-- Stat Cards Row -->
      <div class="row g-3 mb-4">
        <div class="col-sm-6 col-xl-3" *ngFor="let stat of stats()">
          <app-stat-card [stat]="stat" />
        </div>
      </div>

      <!-- Charts Row -->
      <div class="row g-3 mb-4">
        <!-- Revenue chart -->
        <div class="col-lg-8">
          <div class="card border-0 shadow-sm">
            <div class="card-body p-4">
              <div class="d-flex justify-content-between align-items-center mb-3">
                <h6 class="fw-bold mb-0">Revenue Overview</h6>
                <div class="d-flex gap-2">
                  <button
                    *ngFor="let p of ['W','M','Y']"
                    class="btn btn-sm"
                    [class.text-white]="activePeriod() === p"
                    [style.background]="activePeriod() === p ? '#fd4766' : ''"
                    [class.btn-outline-secondary]="activePeriod() !== p"
                    (click)="activePeriod.set(p)"
                  >{{ p }}</button>
                </div>
              </div>
              <canvas #revenueChart height="120"></canvas>
            </div>
          </div>
        </div>

        <!-- Top templates -->
        <div class="col-lg-4">
          <div class="card border-0 shadow-sm h-100">
            <div class="card-body p-4">
              <h6 class="fw-bold mb-3">Top Templates</h6>
              <div *ngFor="let t of topTemplates; let i = index" class="d-flex align-items-center gap-3 mb-3">
                <div
                  class="rounded-2 d-flex align-items-center justify-content-center fw-bold text-white"
                  style="width:36px;height:36px;background:#fd4766;font-size:0.75rem;flex-shrink:0;">
                  {{ i + 1 }}
                </div>
                <div class="flex-grow-1 min-w-0">
                  <p class="fw-semibold small mb-0 text-truncate">{{ t.name }}</p>
                  <p class="text-muted mb-0" style="font-size:0.72rem;">{{ t.sales }} sales</p>
                </div>
                <div class="text-end">
                  <p class="fw-bold small mb-0" style="color:#fd4766;">{{ t.revenue | currency }}</p>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>

    </div>
  `
})
export class StatsDashboardComponent implements OnInit, AfterViewInit, OnDestroy {
  @ViewChild('revenueChart') revenueChartRef!: ElementRef<HTMLCanvasElement>
  private chart?: Chart

  activePeriod = signal('M')

  stats = signal<StatData[]>([
    { title: 'Total Revenue',   value: '$48,294',  change: 12.5,  changeLabel: 'vs last month', icon: '๐Ÿ’ฐ', color: 'success' },
    { title: 'Total Sales',     value: '1,293',    change: 8.1,   changeLabel: 'vs last month', icon: '๐Ÿ›’', color: 'primary' },
    { title: 'Active Users',    value: '9,420',    change: -2.3,  changeLabel: 'vs last month', icon: '๐Ÿ‘ฅ', color: 'warning' },
    { title: 'Avg. Order Value',value: '$37.35',   change: 4.7,   changeLabel: 'vs last month', icon: '๐Ÿ“ฆ', color: 'info'    },
  ])

  topTemplates = [
    { name: 'Marvel Dashboard',    sales: 152, revenue: 7448 },
    { name: 'PORTO Bootstrap',     sales: 134, revenue: 3886 },
    { name: 'Kiosk React',         sales: 98,  revenue: 4802 },
    { name: 'Proctu Medical',      sales: 76,  revenue: 3724 },
    { name: 'ORYO Angular',        sales: 64,  revenue: 3136 },
  ]

  ngOnInit() {}

  ngAfterViewInit() {
    this.initChart()
  }

  initChart() {
    const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
    const data   = [8200, 9100, 11400, 10200, 12800, 13400, 14200, 13800, 12100, 11600, 15200, 18400]

    this.chart = new Chart(this.revenueChartRef.nativeElement, {
      type: 'line',
      data: {
        labels,
        datasets: [{
          label: 'Revenue',
          data,
          borderColor: '#fd4766',
          backgroundColor: 'rgba(253,71,102,0.08)',
          borderWidth: 2,
          fill: true,
          tension: 0.4,
          pointBackgroundColor: '#fd4766',
          pointRadius: 3,
        }]
      },
      options: {
        responsive: true,
        plugins: { legend: { display: false } },
        scales: {
          y: { grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: (v) => '$' + v.toLocaleString() } },
          x: { grid: { display: false } }
        }
      }
    })
  }

  ngOnDestroy() {
    this.chart?.destroy()
  }
}

Sparkline Stat Card

A more compact variant with an inline mini chart:

// sparkline-stat.component.ts
import { Component, Input, AfterViewInit, ViewChild, ElementRef, OnDestroy } from '@angular/core'
import { Chart } from 'chart.js/auto'

@Component({
  selector: 'app-sparkline-stat',
  standalone: true,
  template: `
    <div class="card border-0 shadow-sm">
      <div class="card-body p-4">
        <div class="d-flex justify-content-between align-items-start mb-2">
          <div>
            <p class="text-muted small mb-1">{{ title }}</p>
            <h4 class="fw-bold mb-0">{{ value }}</h4>
          </div>
          <span
            class="badge fw-semibold"
            [style.background]="change >= 0 ? 'rgba(25,135,84,0.12)' : 'rgba(220,53,69,0.12)'"
            [style.color]="change >= 0 ? '#198754' : '#dc3545'"
          >
            {{ change >= 0 ? '+' : '' }}{{ change }}%
          </span>
        </div>
        <canvas #sparkline height="50"></canvas>
      </div>
    </div>
  `
})
export class SparklineStatComponent implements AfterViewInit, OnDestroy {
  @Input() title = ''
  @Input() value = ''
  @Input() change = 0
  @Input() sparkData: number[] = []
  @ViewChild('sparkline') canvasRef!: ElementRef<HTMLCanvasElement>
  private chart?: Chart

  ngAfterViewInit() {
    this.chart = new Chart(this.canvasRef.nativeElement, {
      type: 'line',
      data: {
        labels: this.sparkData.map(() => ''),
        datasets: [{
          data: this.sparkData,
          borderColor: this.change >= 0 ? '#198754' : '#dc3545',
          backgroundColor: this.change >= 0 ? 'rgba(25,135,84,0.1)' : 'rgba(220,53,69,0.1)',
          borderWidth: 2,
          fill: true,
          tension: 0.4,
          pointRadius: 0,
        }]
      },
      options: {
        responsive: true,
        plugins: { legend: { display: false }, tooltip: { enabled: false } },
        scales: { x: { display: false }, y: { display: false } }
      }
    })
  }

  ngOnDestroy() { this.chart?.destroy() }
}

Key Classes Reference

Class / APIPurpose
col-sm-6 col-xl-34-column stat grid, 2 on tablet, 1 on mobile
signal<StatData[]>([])Reactive stats array
ViewChild + ElementRefCanvas reference for Chart.js
ngAfterViewInitSafe to init Chart.js after DOM renders
ngOnDestroy + chart.destroy()Prevent Chart.js memory leaks
CurrencyPipeFormat revenue values
container-fluid px-4Full-width dashboard with gutters

Frequently Asked Questions

Create a StatCardComponent with @Input() props for title, value, change, changeType (up/down/neutral), and icon. Use Bootstrap's card and d-flex utilities for layout. Add a computed() signal to derive the color and arrow based on changeType.
Install chart.js and run npm install chart.js. In your component, import Chart from 'chart.js/auto'. Use a ViewChild to get the canvas element reference, then initialise the chart in ngAfterViewInit. Destroy the chart in ngOnDestroy to prevent memory leaks.
Store your stats in a signal: stats = signal<Stat[]>([]). Fetch data in ngOnInit and call stats.set(data). Use effect() to re-render charts when stats change. Use computed() for derived values like totals and averages.

Related

Need a Full Angular + Bootstrap Admin Dashboard?

Marvel Angular Dashboard โ€” Angular 21 + Bootstrap 5 with 50+ components and dark mode.

Browse Templates โ†’

Use code FIRST30 for 30% off.