KPI card

KPI 카드

The dashboard’s smallest building block: one big number bundled with its delta, a recent trend, and progress toward a goal.

Also known as: Stat tileMetric card스탯 타일
···
html
<div class="viz">
  <div class="tile">
    <div class="lbl">이번 달 매즈</div>
    <div class="row"><div class="num" id="num">0</div><div class="delta" id="delta"></div></div>
    <svg id="spark" class="spark"></svg>
    <div class="goal"><div class="goalbar"><div class="goalfill" id="fill"></div></div><div class="goaltxt" id="goaltxt"></div></div>
  </div>
</div>
css
body{display:block}
.viz{width:100%;height:100%;padding:14px 16px;box-sizing:border-box;display:flex}
.tile{display:flex;flex-direction:column;gap:6px;width:100%;height:100%}
.lbl{font:600 clamp(10px,2.6vmin,13px)/1 system-ui,sans-serif;color:var(--muted)}
.row{display:flex;align-items:baseline;gap:10px}
.num{font:800 clamp(20px,7vmin,34px)/1 system-ui,sans-serif;color:var(--fg)}
.delta{font:700 clamp(10px,2.6vmin,13px)/1 system-ui,sans-serif}
.delta.up{color:var(--accent-3)}
.delta.down{color:var(--accent-2)}
.spark{width:100%;flex:1;min-height:0;display:block}
.goal{display:flex;align-items:center;gap:8px}
.goalbar{flex:1;height:6px;border-radius:3px;background:var(--line);overflow:hidden}
.goalfill{height:100%;background:var(--accent);border-radius:3px}
.goaltxt{font:600 clamp(8px,2vmin,10px)/1 system-ui,sans-serif;color:var(--muted);white-space:nowrap}
js
var svg = document.getElementById('spark');
var numEl = document.getElementById('num');
var deltaEl = document.getElementById('delta');
var fillEl = document.getElementById('fill');
var goalEl = document.getElementById('goaltxt');
var NS = 'http://www.w3.org/2000/svg';
function se(t){ return document.createElementNS(NS,t); }
function sa(e,o){ for (var k in o) e.setAttribute(k,o[k]); }
function R(a,b){ return a + Math.random()*(b-a); }

var GOAL = 100;
var N = 12;
var series = []; var v = 50;
for (var i=0;i<N;i++){ v = Math.max(20,Math.min(90, v+R(-12,12))); series.push(v); }
var cur = series.slice();
var W=300, H=200;

function measure(){
  var r = svg.getBoundingClientRect();
  W = Math.max(r.width,10); H = Math.max(r.height,10);
  sa(svg,{ viewBox: '0 0 ' + W + ' ' + H });
}

function drawSpark(){
  svg.innerHTML = '';
  var padX=2,padY=4;
  var pw=W-padX*2, ph=H-padY*2;
  var pts = cur.map(function(val,i){ return { x:padX+(pw*i)/(N-1), y:padY+ph-(val/100)*ph }; });
  var d = pts.map(function(p,i){ return (i===0?'M':'L')+p.x.toFixed(1)+','+p.y.toFixed(1); }).join(' ');
  var path = se('path');
  sa(path,{ d:d, fill:'none', stroke:'var(--accent)', 'stroke-width':1.8, 'stroke-linecap':'round', 'stroke-linejoin':'round', opacity:.7 });
  svg.appendChild(path);
  var last = pts[pts.length-1];
  var dot = se('circle');
  sa(dot,{ cx:last.x, cy:last.y, r:2.6, fill:'var(--accent)' });
  svg.appendChild(dot);
}

function updateFacts(){
  var val = cur[cur.length-1];
  var prev = cur[cur.length-2];
  numEl.textContent = Math.round(val*8.6).toLocaleString();
  var pct = ((val-prev)/prev*100);
  var up = pct >= 0;
  deltaEl.textContent = (up?'▲ ':'▼ ') + Math.abs(pct).toFixed(1) + '%';
  deltaEl.className = 'delta ' + (up?'up':'down');
  var goalPct = Math.max(4,Math.min(100, (val/GOAL)*100));
  fillEl.style.width = goalPct.toFixed(0)+'%';
  goalEl.textContent = '목표 대비 ' + Math.round(goalPct) + '%';
}

function animate(target){
  var from = cur.slice();
  var t0 = performance.now();
  function step(t){
    var p = Math.min(1,(t-t0)/750);
    var e = 1-Math.pow(1-p,3);
    cur = from.map(function(v2,i){ return v2+(target[i]-v2)*e; });
    drawSpark(); updateFacts();
    if (p<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

function nextTarget(){
  var v2=cur[cur.length-1], out=[];
  for (var i=0;i<N;i++){ v2 = Math.max(15,Math.min(95, v2+R(-14,14))); out.push(v2); }
  return out;
}

new ResizeObserver(function(){ measure(); drawSpark(); }).observe(svg);
measure();
drawSpark();
updateFacts();
animate(nextTarget());
setInterval(function(){ animate(nextTarget()); }, 3600);

If a sparkline is "one trend line tucked beside a number," a KPI card is the assembly that bundles that sparkline together with everything else a number needs for context in one card — the big number (current value), a delta (direction and size versus the last period), a trend (the sparkline), and progress toward a goal (a bar) — four elements, each answering a different question, that reinforce each other.

A number alone gives no basis for "is this a lot or a little." Add a delta and you can tell whether things are improving. Add a trend and you can tell whether that delta is a real shift or just noise. Add goal progress and you can judge whether the current level is actually satisfying. Drop any one of the four and the card answers fewer questions than it looks like it does.

The common mistake is coloring a delta green or red on autopilot, with no target or baseline behind it — revenue going up isn't automatically good news (not if it only rose because marketing spend went up too); color communicates *direction*, and whether that direction is actually good needs a goal to compare against. Overdoing decimal places is another — cram three decimals into a card built for reading one big number at a glance, and the number that matters gets buried in noise.

When to use

Use it to pack a single number’s direction, trend, and goal context into one card for a dashboard grid. To compare several KPIs against their targets in a tight space, use a bullet chart instead.