Funnel chart

퍼널 차트

Shows a value shrinking through sequential stages as narrowing bars — the chart for "which step leaks the most."

Also known as: Conversion funnel전환 퍼널
···
html
<div class="viz">
  <svg id="svg"></svg>
</div>
css
body{display:block}
.viz{position:relative;width:100%;height:100%}
svg{display:block;width:100%;height:100%;overflow:visible}
.tick{fill:var(--muted);font-weight:600}
.minor{display:none}
@media (min-width:460px){.minor{display:inline}}
.f0{fill:color-mix(in srgb, var(--accent) 42%, var(--surface))}
.f1{fill:color-mix(in srgb, var(--accent) 61%, var(--surface))}
.f2{fill:color-mix(in srgb, var(--accent) 80%, var(--surface))}
.f3{fill:var(--accent)}
js
var svg = document.getElementById('svg');
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 LABELS = ['방문','가입','장바구니','결제'];
function genVals(){
  var v = [100];
  for (var i=1;i<LABELS.length;i++) v.push(v[i-1]*R(0.46,0.82));
  return v;
}
var cur = genVals();
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 draw(){
  svg.innerHTML = '';
  var padL=14, padR=14, padT=16, padB=6;
  var pw=W-padL-padR, ph=H-padT-padB;
  var n = cur.length;
  var gap = ph*0.08;
  var rowH = (ph-gap*(n-1))/n;
  var fs = Math.max(8, Math.min(12, W/70));
  var maxV = cur[0];
  cur.forEach(function(v,i){
    var bw = Math.max(14,(v/maxV)*pw);
    var x = padL+(pw-bw)/2;
    var y = padT+i*(rowH+gap);
    var rect = se('rect');
    sa(rect,{x:x,y:y,width:bw,height:rowH,rx:4,class:'f'+i});
    svg.appendChild(rect);
    var lab = se('text');
    sa(lab,{x:W/2,y:y+rowH/2+fs*0.32,'text-anchor':'middle',fill:'var(--fg)','font-size':fs,'font-weight':700,stroke:'var(--surface)','stroke-width':3,'paint-order':'stroke'});
    lab.textContent = LABELS[i]+' '+Math.round(v);
    svg.appendChild(lab);
    if (i>0){
      var pct = se('text');
      sa(pct,{x:W/2,y:y-4,'text-anchor':'middle',class:'tick minor','font-size':fs-1});
      pct.textContent = Math.round(v/cur[i-1]*100)+'%';
      svg.appendChild(pct);
    }
  });
}

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(v,i){ return v+(target[i]-v)*e; });
    draw();
    if (p<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

new ResizeObserver(function(){ measure(); draw(); }).observe(svg);
measure();
draw();
setInterval(function(){ animate(genVals()); }, 3700);

There's one precondition: the stages must be genuinely sequential — visit → signup → cart → purchase, where you can't reach a later stage without passing through the earlier ones. If the categories could be reordered freely, it's just a bar chart wearing a funnel shape.

The number that matters most isn't the bar's absolute width, it's the conversion rate between adjacent stages — a share of the total alone can't tell you whether "this much drop-off is normal here" or "this step just got unusually leaky," so always print the stage-to-stage percentage.

Color should be one hue stepped by lightness (ordinal), tracking the stage order — giving each stage a different hue instead would bury the very fact that this is one ordered flow, not unrelated categories.

When to use

Use it for a genuinely sequential conversion process — visit to signup to purchase — to show drop-off at each step.