KPI 카드

KPI card

큰 숫자 하나에 증감 방향, 최근 추세, 목표 대비 진행률까지 묶어 보여주는 대시보드의 가장 작은 조립 단위.

다른 이름: 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);

스파크라인이 "숫자 옆에 곁들이는 추세선 하나"라면, KPI 카드는 그 스파크라인을 포함해 숫자가 필요로 하는 맥락을 전부 한 카드에 묶은 조립 단위입니다 — 큰 숫자(지금 값), 증감(지난 기간 대비 방향과 크기), 추세(스파크라인), 목표 대비 진행률(막대)까지 네 가지가 각자 다른 질문에 답하면서 서로를 보완합니다.

숫자만 있으면 "많은 건가 적은 건가"를 판단할 기준이 없습니다. 증감이 붙으면 "나아지고 있나"를 알 수 있고, 추세가 붙으면 "그 증감이 우연한 흔들림인지 진짜 흐름인지"를 구분할 수 있고, 목표 진행률이 붙으면 "이 정도면 만족스러운 수준인지"까지 판단할 수 있습니다 — 넷 중 하나만 빼도 카드가 답할 수 있는 질문이 줄어듭니다.

흔한 실수는 목표나 비교 기준 없이 증감률에 무조건 초록/빨강을 입히는 것입니다 — 매출이 늘었다고 항상 좋은 신호는 아니고(예: 마케팅비를 늘려서 늘었다면), 색이 "방향"만 말하고 "그게 좋은 방향인지"는 목표와 비교해야 알 수 있습니다. 소수점을 과하게 표시하는 것도 문제입니다 — 큰 숫자를 한눈에 읽는 카드에 소수점 셋째 자리까지 넣으면 오히려 핵심 숫자가 묻힙니다.

언제 쓰나

숫자 하나에 방향·추세·목표 맥락까지 한 카드에 압축해 대시보드에 나란히 배치하고 싶을 때. 여러 KPI를 목표 대비로 촘촘히 비교하려면 불릿 차트를 쓰세요.