KPI Card Grid

Store-driven metrics dashboard with multiple subscribing cards.

Live Demo

Updates every 3 seconds.

Source

HTML + JavaScript
<div data-component="kpi-grid">
    <div class="kpi-grid" data-list="metrics">
        <template>
            <div class="kpi-card">
                <div data-bind-style="{ color: color }"
                     data-bind="formattedValue"></div>
                <div data-bind="label"></div>
                <div data-bind-class="trend > 0 ? 'kpi-up' : 'kpi-down'"
                     data-bind="trendLabel"></div>
            </div>
        </template>
    </div>
</div>

<script>
wildflower.component('kpi-grid', {
    state: {
        running: true,
        metrics: [
            { label: 'Revenue', value: 48200, format: 'currency', color: '#6b996a', trend: 12.5 },
            { label: 'Users', value: 3842, format: 'number', color: '#3498db', trend: 8.2 },
            { label: 'Errors', value: 23, format: 'number', color: '#c0392b', trend: -15.1 },
            { label: 'Uptime', value: 99.97, format: 'percent', color: '#6b996a', trend: 0.1 }
        ]
    },
    computed: {
        // Declaring the (m) parameter makes these item-level: the
        // framework calls them per row with the list item.
        formattedValue(m) {
            if (m.format === 'currency') return '$' + m.value.toLocaleString();
            if (m.format === 'percent') return m.value + '%';
            return m.value.toLocaleString();
        },
        trendLabel(m) {
            return (m.trend > 0 ? '▲ +' : '▼ ') + m.trend + '%';
        }
    },
    init() {
        this._interval = setInterval(() => {
            if (!this.running) return;
            this.metrics.forEach(m => {
                const prev = m.value;
                if (m.format === 'percent') {
                    const delta = m.value * (Math.random() * 0.04 - 0.02);
                    m.value = Math.min(100, Math.round((m.value + delta) * 100) / 100);
                } else {
                    // Integer walk, at least +/-1 so small counts move.
                    let step = Math.round(m.value * (Math.random() * 0.04 - 0.02));
                    if (step === 0) step = Math.random() < 0.5 ? -1 : 1;
                    m.value = Math.max(0, m.value + step);
                }
                // Trend = real % change this tick, so the arrow matches the move.
                m.trend = prev ? Math.round(((m.value - prev) / prev) * 1000) / 10 : 0;
            });
        }, 3000);
    },
    toggleUpdates() { this.running = !this.running; },
    destroy() { clearInterval(this._interval); }
});
</script>

Key Points

  • data-bind-style="{ color: color }" applies inline styles from item data. Each metric gets its own color
  • data-bind-class="trend > 0 ? 'kpi-up' : 'kpi-down'" conditionally styles the trend indicator
  • Item-level computeds (formattedValue, trendLabel) format data per list item; declaring a parameter (formattedValue(m)) is what tells the framework to call them per row with the item
  • init()/destroy() lifecycle manages the update interval
  • Nested mutations (m.value = ...) inside forEach propagate reactively