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 / API | Purpose |
|---|---|
col-sm-6 col-xl-3 | 4-column stat grid, 2 on tablet, 1 on mobile |
signal<StatData[]>([]) | Reactive stats array |
ViewChild + ElementRef | Canvas reference for Chart.js |
ngAfterViewInit | Safe to init Chart.js after DOM renders |
ngOnDestroy + chart.destroy() | Prevent Chart.js memory leaks |
CurrencyPipe | Format revenue values |
container-fluid px-4 | Full-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.