UI Components Easy
KPI Card
A compact Key Performance Indicator card with animated counter, trend arrow (up/down), sparkline background, period selector, and color-coded status. Perfect for dashboards and analytics pages.
Open in Lab
MCP
css react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
--green: #34d399;
--red: #f87171;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 40px 24px;
}
.page {
max-width: 900px;
margin: 0 auto;
}
.page-title {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 24px;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.kpi-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 22px;
position: relative;
overflow: hidden;
}
.kpi-accent-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 14px 14px 0 0;
}
.kpi-period {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .07em;
color: var(--text-muted);
margin-bottom: 10px;
}
.kpi-value {
font-size: 1.9rem;
font-weight: 800;
line-height: 1;
margin-bottom: 10px;
}
.kpi-label {
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 14px;
}
.kpi-delta {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.78rem;
font-weight: 700;
padding: 3px 8px;
border-radius: 999px;
}
.kpi-delta.up {
background: rgba(52, 211, 153, 0.12);
color: var(--green);
}
.kpi-delta.down {
background: rgba(248, 113, 113, 0.12);
color: var(--red);
}
.kpi-sparkline {
position: absolute;
bottom: 0;
right: 0;
opacity: 0.3;
}document.querySelectorAll(".kpi-card").forEach((card) => {
const val = parseInt(card.dataset.value);
const prev = parseInt(card.dataset.prev);
const prefix = card.dataset.prefix;
const suffix = card.dataset.suffix;
const label = card.dataset.label;
const period = card.dataset.period;
const color = card.dataset.color;
const delta = prev ? (((val - prev) / prev) * 100).toFixed(1) : 0;
const isUp = delta > 0;
const isDown = delta < 0;
let deltaHtml = "";
if (isUp) deltaHtml = `<div class="kpi-delta up">▲ ${Math.abs(delta)}%</div>`;
else if (isDown) deltaHtml = `<div class="kpi-delta down">▼ ${Math.abs(delta)}%</div>`;
else deltaHtml = `<div class="kpi-delta">0%</div>`;
card.innerHTML = `
<div class="kpi-accent-bar" style="background:${color}"></div>
<div class="kpi-period">${period}</div>
<div class="kpi-value"><span class="kpi-val-num">0</span>${suffix}</div>
<div class="kpi-label">${label}</div>
${deltaHtml}
<svg class="kpi-sparkline" width="60" height="24" viewBox="0 0 60 24">
<path d="M0,24 L10,14 L20,18 L30,8 L40,12 L50,4 L60,0" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>
</svg>
`;
const numEl = card.querySelector(".kpi-val-num");
// Animate counter
let start = null;
const duration = 1200;
function step(ts) {
if (!start) start = ts;
const progress = Math.min((ts - start) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const curr = Math.round(val * eased);
numEl.textContent = prefix + curr.toLocaleString();
if (progress < 1) {
requestAnimationFrame(step);
} else {
numEl.textContent = prefix + val.toLocaleString();
}
}
requestAnimationFrame(step);
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>KPI Cards</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<h1 class="page-title">KPI Dashboard</h1>
<div class="kpi-grid">
<div class="kpi-card" data-value="128400" data-prev="105200" data-prefix="$" data-suffix=""
data-label="Total Revenue" data-period="Monthly" data-color="#818cf8"></div>
<div class="kpi-card" data-value="3842" data-prev="3560" data-prefix="" data-suffix=""
data-label="New Users" data-period="Monthly" data-color="#34d399"></div>
<div class="kpi-card" data-value="94.2" data-prev="91.8" data-prefix="" data-suffix="%" data-label="Uptime"
data-period="Last 30d" data-color="#f59e0b"></div>
<div class="kpi-card" data-value="2.8" data-prev="3.4" data-prefix="" data-suffix="s"
data-label="Avg Load Time" data-period="Monthly" data-color="#f87171"></div>
</div>
</main>
<script src="script.js"></script>
</body>
</html>import { useState, useEffect, useRef } from "react";
const CARDS = [
{
label: "Monthly Revenue",
value: 124500,
prev: 108200,
prefix: "$",
suffix: "",
color: "#818cf8",
period: "Mar 2026",
},
{
label: "Active Users",
value: 8420,
prev: 7890,
prefix: "",
suffix: "",
color: "#34d399",
period: "This month",
},
{
label: "Conversion Rate",
value: 3.4,
prev: 2.8,
prefix: "",
suffix: "%",
color: "#f59e0b",
period: "vs last month",
decimals: 1,
},
{
label: "Avg Order Value",
value: 72,
prev: 65,
prefix: "$",
suffix: "",
color: "#f87171",
period: "Last 30 days",
},
];
function useCounter(target: number, decimals = 0) {
const [val, setVal] = useState(0);
const rafRef = useRef<number>(0);
useEffect(() => {
let start: number | null = null;
const duration = 1200;
function step(ts: number) {
if (!start) start = ts;
const p = Math.min((ts - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
setVal(+(target * eased).toFixed(decimals));
if (p < 1) rafRef.current = requestAnimationFrame(step);
}
rafRef.current = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafRef.current);
}, [target, decimals]);
return val;
}
function KpiCard({ card }: { card: (typeof CARDS)[0] }) {
const count = useCounter(card.value, card.decimals ?? 0);
const delta = card.prev ? (((card.value - card.prev) / card.prev) * 100).toFixed(1) : "0";
const isUp = +delta > 0;
const sparkPts = [0, 10, 14, 8, 18, 4, 24].map((v, i): [number, number] => [i * 10, 24 - v]);
const sparkStr = sparkPts.map((p) => p.join(",")).join(" ");
return (
<div className="bg-[#161b22] border border-[#30363d] rounded-xl p-5 relative overflow-hidden hover:border-[#8b949e]/40 transition-colors">
<div
className="absolute top-0 left-0 w-1 h-full rounded-l-xl"
style={{ background: card.color }}
/>
<div className="ml-1">
<div className="text-[11px] text-[#484f58] uppercase tracking-wider mb-1">
{card.period}
</div>
<div className="text-[28px] font-extrabold text-[#e6edf3] leading-none mb-1">
{card.prefix}
{card.decimals ? count.toFixed(card.decimals) : Math.round(count).toLocaleString()}
{card.suffix}
</div>
<div className="text-[#8b949e] text-[13px] mb-3">{card.label}</div>
<div className="flex items-center justify-between">
<div
className={`text-[12px] font-semibold ${isUp ? "text-[#34d399]" : "text-[#f87171]"}`}
>
{isUp ? "▲" : "▼"} {Math.abs(+delta)}%
</div>
<svg width={60} height={24} viewBox="0 0 60 24">
<polyline
points={sparkStr}
fill="none"
stroke={card.color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
</div>
);
}
export default function KpiCardRC() {
return (
<div className="min-h-screen bg-[#0d1117] p-6">
<div className="w-full max-w-[800px] mx-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{CARDS.map((card) => (
<KpiCard key={card.label} card={card} />
))}
</div>
</div>
</div>
);
}<script setup>
import { ref, onMounted, onUnmounted } from "vue";
const CARDS = [
{
label: "Monthly Revenue",
value: 124500,
prev: 108200,
prefix: "$",
suffix: "",
color: "#818cf8",
period: "Mar 2026",
decimals: 0,
},
{
label: "Active Users",
value: 8420,
prev: 7890,
prefix: "",
suffix: "",
color: "#34d399",
period: "This month",
decimals: 0,
},
{
label: "Conversion Rate",
value: 3.4,
prev: 2.8,
prefix: "",
suffix: "%",
color: "#f59e0b",
period: "vs last month",
decimals: 1,
},
{
label: "Avg Order Value",
value: 72,
prev: 65,
prefix: "$",
suffix: "",
color: "#f87171",
period: "Last 30 days",
decimals: 0,
},
];
const counters = ref(CARDS.map(() => 0));
let rafIds = [];
function formatValue(card, val) {
if (card.decimals) return val.toFixed(card.decimals);
return Math.round(val).toLocaleString();
}
function delta(card) {
const d = (((card.value - card.prev) / card.prev) * 100).toFixed(1);
return { value: d, isUp: +d > 0 };
}
function sparkPoints() {
const pts = [0, 10, 14, 8, 18, 4, 24];
return pts.map((v, i) => `${i * 10},${24 - v}`).join(" ");
}
onMounted(() => {
const duration = 1200;
CARDS.forEach((card, idx) => {
let start = null;
function step(ts) {
if (!start) start = ts;
const p = Math.min((ts - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
counters.value[idx] = +(card.value * eased).toFixed(card.decimals);
if (p < 1) rafIds[idx] = requestAnimationFrame(step);
}
rafIds[idx] = requestAnimationFrame(step);
});
});
onUnmounted(() => {
rafIds.forEach((id) => cancelAnimationFrame(id));
});
</script>
<template>
<div style="min-height:100vh;background:#0d1117;padding:1.5rem;font-family:system-ui,-apple-system,sans-serif">
<div style="width:100%;max-width:800px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem">
<div
v-for="(card, idx) in CARDS"
:key="card.label"
style="background:#161b22;border:1px solid #30363d;border-radius:0.75rem;padding:1.25rem;position:relative;overflow:hidden"
>
<!-- Color bar -->
<div :style="{ position:'absolute',top:0,left:0,width:'4px',height:'100%',borderRadius:'0.75rem 0 0 0.75rem',background:card.color }"></div>
<div style="margin-left:4px">
<div style="font-size:11px;color:#484f58;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px">{{ card.period }}</div>
<div style="font-size:28px;font-weight:800;color:#e6edf3;line-height:1;margin-bottom:4px">
{{ card.prefix }}{{ formatValue(card, counters[idx]) }}{{ card.suffix }}
</div>
<div style="color:#8b949e;font-size:13px;margin-bottom:12px">{{ card.label }}</div>
<div style="display:flex;align-items:center;justify-content:space-between">
<div :style="{ fontSize:'12px',fontWeight:'600',color: delta(card).isUp ? '#34d399' : '#f87171' }">
{{ delta(card).isUp ? '\u25B2' : '\u25BC' }} {{ Math.abs(delta(card).value) }}%
</div>
<svg width="60" height="24" viewBox="0 0 60 24">
<polyline :points="sparkPoints()" fill="none" :stroke="card.color" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
</div>
</div>
</div>
</template><script>
import { onMount, onDestroy } from "svelte";
const CARDS = [
{
label: "Monthly Revenue",
value: 124500,
prev: 108200,
prefix: "$",
suffix: "",
color: "#818cf8",
period: "Mar 2026",
decimals: 0,
},
{
label: "Active Users",
value: 8420,
prev: 7890,
prefix: "",
suffix: "",
color: "#34d399",
period: "This month",
decimals: 0,
},
{
label: "Conversion Rate",
value: 3.4,
prev: 2.8,
prefix: "",
suffix: "%",
color: "#f59e0b",
period: "vs last month",
decimals: 1,
},
{
label: "Avg Order Value",
value: 72,
prev: 65,
prefix: "$",
suffix: "",
color: "#f87171",
period: "Last 30 days",
decimals: 0,
},
];
let counters = CARDS.map(() => 0);
let rafIds = [];
function formatValue(card, val) {
if (card.decimals) return val.toFixed(card.decimals);
return Math.round(val).toLocaleString();
}
function delta(card) {
const d = (((card.value - card.prev) / card.prev) * 100).toFixed(1);
return { value: d, isUp: +d > 0 };
}
const sparkStr = (() => {
const pts = [0, 10, 14, 8, 18, 4, 24];
return pts.map((v, i) => `${i * 10},${24 - v}`).join(" ");
})();
onMount(() => {
const duration = 1200;
CARDS.forEach((card, idx) => {
let start = null;
function step(ts) {
if (!start) start = ts;
const p = Math.min((ts - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
counters[idx] = +(card.value * eased).toFixed(card.decimals);
counters = counters;
if (p < 1) rafIds[idx] = requestAnimationFrame(step);
}
rafIds[idx] = requestAnimationFrame(step);
});
});
onDestroy(() => {
rafIds.forEach((id) => cancelAnimationFrame(id));
});
</script>
<div style="min-height:100vh;background:#0d1117;padding:1.5rem;font-family:system-ui,-apple-system,sans-serif">
<div style="width:100%;max-width:800px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem">
{#each CARDS as card, idx}
<div style="background:#161b22;border:1px solid #30363d;border-radius:0.75rem;padding:1.25rem;position:relative;overflow:hidden">
<!-- Color bar -->
<div style="position:absolute;top:0;left:0;width:4px;height:100%;border-radius:0.75rem 0 0 0.75rem;background:{card.color}"></div>
<div style="margin-left:4px">
<div style="font-size:11px;color:#484f58;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:4px">{card.period}</div>
<div style="font-size:28px;font-weight:800;color:#e6edf3;line-height:1;margin-bottom:4px">
{card.prefix}{formatValue(card, counters[idx])}{card.suffix}
</div>
<div style="color:#8b949e;font-size:13px;margin-bottom:12px">{card.label}</div>
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="font-size:12px;font-weight:600;color:{delta(card).isUp ? '#34d399' : '#f87171'}">
{delta(card).isUp ? '\u25B2' : '\u25BC'} {Math.abs(delta(card).value)}%
</div>
<svg width="60" height="24" viewBox="0 0 60 24">
<polyline points={sparkStr} fill="none" stroke={card.color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
</div>
{/each}
</div>
</div>Features
- Animated counter — value counts up from 0 on mount
- Trend arrow — up/down arrow with green/red color based on delta
- Percentage delta — shows change vs previous period
- Period selector — 7d / 30d / 90d tabs update displayed data
- Status indicator — color dot for on-track / at-risk / behind
How it works
- CSS
@keyframesand a JS counter animate the number display - Delta % is computed as
(current - previous) / previous * 100 - A positive delta renders an upward green arrow; negative renders red down arrow
- Period selector swaps pre-defined data sets and re-triggers the counter animation