UI Components Easy
Metric Comparison
A before/after metric comparison widget that highlights the difference between two values with an animated transition bar, percentage change label, and color-coded improvement/regression indicators.
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: 700px;
margin: 0 auto;
}
.page-title {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 24px;
}
.mc-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.mc-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px 20px;
}
.mc-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.mc-label {
font-size: 0.875rem;
font-weight: 700;
}
.mc-delta {
font-size: 0.78rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
}
.mc-delta.up {
background: rgba(52, 211, 153, 0.12);
color: var(--green);
}
.mc-delta.down {
background: rgba(248, 113, 113, 0.12);
color: var(--red);
}
.mc-bars {
display: flex;
flex-direction: column;
gap: 8px;
}
.mc-bar-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.75rem;
color: var(--text-muted);
}
.mc-bar-label {
width: 80px;
flex-shrink: 0;
}
.mc-bar-track {
flex: 1;
background: var(--surface2);
border-radius: 999px;
height: 8px;
overflow: hidden;
}
.mc-bar-fill {
height: 100%;
border-radius: 999px;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
}
.mc-bar-val {
width: 60px;
text-align: right;
font-weight: 600;
}const METRICS = [
{ label: "Website Visits", before: 45200, after: 52100, format: "num" },
{ label: "Bounce Rate", before: 54.2, after: 48.6, format: "pct", reverse: true }, // lower is better
{ label: "Conversion", before: 2.8, after: 3.4, format: "pct" },
{ label: "Avg Order", before: 65, after: 72, format: "currency" },
];
const grid = document.getElementById("mcGrid");
METRICS.forEach((m) => {
const card = document.createElement("div");
card.className = "mc-card";
const delta = (((m.after - m.before) / m.before) * 100).toFixed(1);
const isBetter = m.reverse ? delta < 0 : delta > 0;
const isSame = delta == 0;
let deltaCls = isSame ? "" : isBetter ? "up" : "down";
let deltaSym = isSame ? "" : delta > 0 ? "▲" : "▼";
const maxVal = Math.max(m.before, m.after) * 1.1;
function fmt(v) {
if (m.format === "pct") return v + "%";
if (m.format === "currency") return "$" + v;
return v.toLocaleString();
}
card.innerHTML = `
<div class="mc-card-header">
<div class="mc-label">${m.label}</div>
<div class="mc-delta ${deltaCls}">${deltaSym} ${Math.abs(delta)}%</div>
</div>
<div class="mc-bars">
<div class="mc-bar-row">
<div class="mc-bar-label">Last Month</div>
<div class="mc-bar-track">
<div class="mc-bar-fill" style="width: 0%; background: var(--text-muted);" data-w="${(m.before / maxVal) * 100}%"></div>
</div>
<div class="mc-bar-val">${fmt(m.before)}</div>
</div>
<div class="mc-bar-row">
<div class="mc-bar-label" style="color:var(--text);font-weight:600;">This Month</div>
<div class="mc-bar-track">
<div class="mc-bar-fill" style="width: 0%; background: #818cf8;" data-w="${(m.after / maxVal) * 100}%"></div>
</div>
<div class="mc-bar-val" style="color:var(--text);">${fmt(m.after)}</div>
</div>
</div>
`;
grid.appendChild(card);
// Trigger animation after slight delay
setTimeout(() => {
card.querySelectorAll(".mc-bar-fill").forEach((fill) => {
fill.style.width = fill.dataset.w;
});
}, 100);
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Metric Comparison</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<h1 class="page-title">Month over Month</h1>
<div class="mc-grid" id="mcGrid"></div>
</main>
<script src="script.js"></script>
</body>
</html>import { useEffect, useRef, useState } from "react";
const METRICS = [
{ label: "Website Visits", before: 45200, after: 52100, format: "num" as const },
{ label: "Bounce Rate", before: 54.2, after: 48.6, format: "pct" as const, reverse: true },
{ label: "Conversion", before: 2.8, after: 3.4, format: "pct" as const },
{ label: "Avg Order", before: 65, after: 72, format: "currency" as const },
];
function fmt(v: number, f: string) {
if (f === "pct") return v + "%";
if (f === "currency") return "$" + v;
return v.toLocaleString();
}
function MetricBar({
label,
value,
max,
color,
delay,
}: { label: string; value: number; max: number; color: string; delay: number }) {
const ref = useRef<HTMLDivElement>(null);
const pct = ((value / max) * 100).toFixed(1) + "%";
useEffect(() => {
const t = setTimeout(() => {
if (ref.current) ref.current.style.width = pct;
}, delay);
return () => clearTimeout(t);
}, [pct, delay]);
return (
<div className="flex items-center gap-2">
<div className="text-[11px] w-24 text-right flex-shrink-0" style={{ color }}>
{label}
</div>
<div className="flex-1 h-5 bg-[#21262d] rounded-full overflow-hidden">
<div
ref={ref}
className="h-full rounded-full transition-[width] duration-700 ease-out"
style={{ width: 0, background: color }}
/>
</div>
</div>
);
}
export default function MetricComparisonRC() {
return (
<div className="min-h-screen bg-[#0d1117] p-6 flex justify-center">
<div className="w-full max-w-[640px] grid grid-cols-1 sm:grid-cols-2 gap-4">
{METRICS.map((m) => {
const delta = (((m.after - m.before) / m.before) * 100).toFixed(1);
const isBetter = m.reverse ? +delta < 0 : +delta > 0;
const maxVal = Math.max(m.before, m.after) * 1.1;
return (
<div
key={m.label}
className="bg-[#161b22] border border-[#30363d] rounded-xl p-5 hover:border-[#8b949e]/40 transition-colors"
>
<div className="flex items-center justify-between mb-4">
<div className="text-[#e6edf3] font-semibold text-[14px]">{m.label}</div>
<div
className={`text-[12px] font-bold px-2 py-0.5 rounded-full ${isBetter ? "bg-[#34d399]/10 text-[#34d399]" : "bg-[#f87171]/10 text-[#f87171]"}`}
>
{+delta > 0 ? "▲" : "▼"}
{Math.abs(+delta)}%
</div>
</div>
<div className="space-y-3">
<div>
<MetricBar
label="Last Month"
value={m.before}
max={maxVal}
color="#484f58"
delay={100}
/>
<div className="text-[11px] text-[#484f58] mt-1 text-right">
{fmt(m.before, m.format)}
</div>
</div>
<div>
<MetricBar
label="This Month"
value={m.after}
max={maxVal}
color="#818cf8"
delay={200}
/>
<div className="text-[11px] text-[#818cf8] mt-1 text-right font-semibold">
{fmt(m.after, m.format)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}<script setup>
import { ref, onMounted } from "vue";
const METRICS = [
{ label: "Website Visits", before: 45200, after: 52100, format: "num" },
{ label: "Bounce Rate", before: 54.2, after: 48.6, format: "pct", reverse: true },
{ label: "Conversion", before: 2.8, after: 3.4, format: "pct" },
{ label: "Avg Order", before: 65, after: 72, format: "currency" },
];
function fmt(v, f) {
if (f === "pct") return v + "%";
if (f === "currency") return "$" + v;
return v.toLocaleString();
}
const barWidths = ref({});
const ready = ref(false);
onMounted(() => {
setTimeout(() => {
ready.value = true;
}, 50);
});
function barPct(value, max) {
return ((value / max) * 100).toFixed(1) + "%";
}
</script>
<template>
<div style="min-height:100vh;background:#0d1117;padding:1.5rem;display:flex;justify-content:center;font-family:system-ui,-apple-system,sans-serif;color:#e6edf3">
<div style="width:100%;max-width:640px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem">
<div
v-for="m in METRICS"
:key="m.label"
style="background:#161b22;border:1px solid #30363d;border-radius:0.75rem;padding:1.25rem"
>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem">
<div style="font-weight:600;font-size:14px">{{ m.label }}</div>
<div
:style="{
fontSize: '12px',
fontWeight: '700',
padding: '0.125rem 0.5rem',
borderRadius: '9999px',
background: (m.reverse ? ((m.after - m.before) / m.before * 100) < 0 : ((m.after - m.before) / m.before * 100) > 0) ? 'rgba(52,211,153,0.1)' : 'rgba(248,113,113,0.1)',
color: (m.reverse ? ((m.after - m.before) / m.before * 100) < 0 : ((m.after - m.before) / m.before * 100) > 0) ? '#34d399' : '#f87171',
}"
>
{{ ((m.after - m.before) / m.before * 100) > 0 ? '\u25B2' : '\u25BC' }}{{ Math.abs(((m.after - m.before) / m.before * 100)).toFixed(1) }}%
</div>
</div>
<div style="display:flex;flex-direction:column;gap:0.75rem">
<!-- Before bar -->
<div>
<div style="display:flex;align-items:center;gap:0.5rem">
<div style="font-size:11px;width:6rem;text-align:right;flex-shrink:0;color:#484f58">Last Month</div>
<div style="flex:1;height:1.25rem;background:#21262d;border-radius:9999px;overflow:hidden">
<div
:style="{
height: '100%',
borderRadius: '9999px',
background: '#484f58',
transition: 'width 0.7s ease-out',
width: ready ? barPct(m.before, Math.max(m.before, m.after) * 1.1) : '0%',
}"
/>
</div>
</div>
<div style="font-size:11px;color:#484f58;margin-top:0.25rem;text-align:right">{{ fmt(m.before, m.format) }}</div>
</div>
<!-- After bar -->
<div>
<div style="display:flex;align-items:center;gap:0.5rem">
<div style="font-size:11px;width:6rem;text-align:right;flex-shrink:0;color:#818cf8">This Month</div>
<div style="flex:1;height:1.25rem;background:#21262d;border-radius:9999px;overflow:hidden">
<div
:style="{
height: '100%',
borderRadius: '9999px',
background: '#818cf8',
transition: 'width 0.7s ease-out',
width: ready ? barPct(m.after, Math.max(m.before, m.after) * 1.1) : '0%',
}"
/>
</div>
</div>
<div style="font-size:11px;color:#818cf8;margin-top:0.25rem;text-align:right;font-weight:600">{{ fmt(m.after, m.format) }}</div>
</div>
</div>
</div>
</div>
</div>
</template><script>
import { onMount } from "svelte";
const METRICS = [
{ label: "Website Visits", before: 45200, after: 52100, format: "num" },
{ label: "Bounce Rate", before: 54.2, after: 48.6, format: "pct", reverse: true },
{ label: "Conversion", before: 2.8, after: 3.4, format: "pct" },
{ label: "Avg Order", before: 65, after: 72, format: "currency" },
];
function fmt(v, f) {
if (f === "pct") return v + "%";
if (f === "currency") return "$" + v;
return v.toLocaleString();
}
function delta(m) {
return ((m.after - m.before) / m.before) * 100;
}
function isBetter(m) {
const d = delta(m);
return m.reverse ? d < 0 : d > 0;
}
function barPct(value, max) {
return ((value / max) * 100).toFixed(1) + "%";
}
let ready = false;
onMount(() => {
setTimeout(() => {
ready = true;
}, 50);
});
</script>
<div style="min-height:100vh;background:#0d1117;padding:1.5rem;display:flex;justify-content:center;font-family:system-ui,-apple-system,sans-serif;color:#e6edf3">
<div style="width:100%;max-width:640px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem">
{#each METRICS as m}
<div style="background:#161b22;border:1px solid #30363d;border-radius:0.75rem;padding:1.25rem">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem">
<div style="font-weight:600;font-size:14px">{m.label}</div>
<div style="font-size:12px;font-weight:700;padding:0.125rem 0.5rem;border-radius:9999px;background:{isBetter(m) ? 'rgba(52,211,153,0.1)' : 'rgba(248,113,113,0.1)'};color:{isBetter(m) ? '#34d399' : '#f87171'}">
{delta(m) > 0 ? '\u25B2' : '\u25BC'}{Math.abs(delta(m)).toFixed(1)}%
</div>
</div>
<div style="display:flex;flex-direction:column;gap:0.75rem">
<div>
<div style="display:flex;align-items:center;gap:0.5rem">
<div style="font-size:11px;width:6rem;text-align:right;flex-shrink:0;color:#484f58">Last Month</div>
<div style="flex:1;height:1.25rem;background:#21262d;border-radius:9999px;overflow:hidden">
<div style="height:100%;border-radius:9999px;background:#484f58;transition:width 0.7s ease-out;width:{ready ? barPct(m.before, Math.max(m.before, m.after) * 1.1) : '0%'}"></div>
</div>
</div>
<div style="font-size:11px;color:#484f58;margin-top:0.25rem;text-align:right">{fmt(m.before, m.format)}</div>
</div>
<div>
<div style="display:flex;align-items:center;gap:0.5rem">
<div style="font-size:11px;width:6rem;text-align:right;flex-shrink:0;color:#818cf8">This Month</div>
<div style="flex:1;height:1.25rem;background:#21262d;border-radius:9999px;overflow:hidden">
<div style="height:100%;border-radius:9999px;background:#818cf8;transition:width 0.7s ease-out;width:{ready ? barPct(m.after, Math.max(m.before, m.after) * 1.1) : '0%'}"></div>
</div>
</div>
<div style="font-size:11px;color:#818cf8;margin-top:0.25rem;text-align:right;font-weight:600">{fmt(m.after, m.format)}</div>
</div>
</div>
</div>
{/each}
</div>
</div>Features
- Before / After bars — two progress bars with animated width fill
- Delta badge — absolute and percentage change highlighted in green/red
- Animated transition — bars animate from before to after on load
- Multiple metrics — support for comparing several metrics in a grid
- Labels — configurable period labels (e.g., “This month” vs “Last month”)
How it works
- Both bars are CSS
widthtransitions triggered on mount viarequestAnimationFrame - Delta is computed as
(after - before) / before * 100and rounded - Positive delta gets green; negative gets red with a ▼ indicator
- Bars share a common
maxvalue so relative proportions are preserved