← Charts

Funnel Stages

A sales or sign-up funnel. Each stage drops in under the one above, then narrows to its share of it, and the conversion rate from the stage before pops in beside it and counts up.

VisitedViewed pricingStarted trialAdded cardPaid

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-funnel-stages to a container with a height, with one child per stage, widest first: its text is the label and data-value its size.

Options

Add any of these attributes to the same element.

data-colorOn a stage: its colour. Default purple at the top shading to pink.
data-ratetotal shows each stage as a share of the first. Default: a share of the stage above.
data-ratesfalse hides the conversion rates.
data-minNarrowest width, as a share of the widest. Default 0.3.
data-prefix / data-suffixText around the values, e.g. $.
data-durationTime for each stage to drop in and narrow. Default 700 ms.
data-staggerGap between stages. Default 260 ms.
data-loopEmpty and fill again every few seconds.

Where a stage gets too narrow for its label, it shows just the value. Screen readers hear every stage with its value and rate.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Funnel Stages: the stages of a funnel drop in one after another, each
    // narrowing to its share of the stage above, and the conversion rate
    // between them pops in beside the funnel and counts up.
    // Usage:
    //   <div data-funnel-stages style="height:260px">
    //     <span data-value="12400">Visited</span>
    //     <span data-value="5200">Signed up</span>
    //     <span data-value="1240">Paid</span>
    //   </div>
    // Each child is one stage, widest first: its text is the label and
    // data-value its size.
    // Optional: data-color (on a stage: its colour; default purple to pink),
    // data-rate="total" (rates as a share of the first stage; default each
    // stage's share of the one above), data-rates="false" (no rates),
    // data-min (narrowest width as a share of the widest, default 0.3),
    // data-prefix / data-suffix (around the values), data-duration (ms for
    // each stage, default 700), data-stagger (ms between stages, default 260),
    // data-loop (empty and fill again every few seconds).
    var DEFAULTS = { duration: 700, stagger: 260, hold: 2800, min: 0.3 };
    var FROM = [124, 58, 237], TO = [236, 72, 153];
    var SIDE = 64;

    var css =
      '.fns{display:flex;flex-direction:column;gap:5px;font-variant-numeric:tabular-nums}' +
      '.fns-row{position:relative;flex:1;min-height:0;opacity:0}' +
      '.fns-area{position:absolute;top:0;bottom:0;left:0;right:var(--fns-side)}' +
      '.fns-shape{position:absolute;inset:0;background:var(--fns-c)}' +
      '.fns-text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:.45em;color:#fff;font-size:13px;font-weight:700;line-height:1.1;white-space:nowrap;text-shadow:0 1px 2px rgba(0,0,0,.18);opacity:0}' +
      '.fns-text small{font-size:inherit;font-weight:600;opacity:.82}' +
      '.fns-short .fns-name{display:none}' +
      '.fns-rate{position:absolute;top:-2.5px;transform:translateY(-50%);padding:2px 7px;border-radius:99px;background:rgba(127,127,127,.14);font-size:11px;font-weight:700;line-height:15px;white-space:nowrap;opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
    function mix(t) {
      return 'rgb(' + FROM.map(function (a, i) { return Math.round(a + (TO[i] - a) * t); }).join(',') + ')';
    }
    function pct(r) { var p = r * 100; return (p < 10 ? p.toFixed(1) : Math.round(p)) + '%'; }

    function setup(el) {
      var d = el.dataset;
      var min = d.min !== undefined ? +d.min : DEFAULTS.min;
      var stages = Array.prototype.slice.call(el.children).map(function (s) {
        return { label: s.textContent.trim(), value: +s.getAttribute('data-value') || 0, color: s.getAttribute('data-color') };
      });
      var n = stages.length, top = stages[0] ? stages[0].value || 1 : 1;
      var showRates = d.rates !== 'false';
      var pre = d.prefix || '', suf = d.suffix || '';
      stages.forEach(function (s, i) {
        s.w = min + (1 - min) * Math.min(1, s.value / top);
        s.color = s.color || mix(n > 1 ? i / (n - 1) : 0);
        s.rate = i ? s.value / (d.rate === 'total' ? top : stages[i - 1].value || 1) : 0;
      });
      // Each stage is a trapezoid from its own width down to the next one's.
      stages.forEach(function (s, i) { s.wb = i < n - 1 ? stages[i + 1].w : s.w * 0.86; });

      el.classList.add('fns');
      el.style.setProperty('--fns-side', showRates ? SIDE + 'px' : '0px');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Funnel. ' + stages.map(function (s, i) {
        return s.label + ': ' + pre + s.value.toLocaleString() + suf + (i && showRates ? ' (' + pct(s.rate) + ')' : '');
      }).join('. '));
      el.innerHTML = stages.map(function (s, i) {
        return '<div class="fns-row" aria-hidden="true" style="--fns-c:' + esc(s.color) + '">' +
          '<div class="fns-area"><div class="fns-shape"></div>' +
          '<div class="fns-text"><span class="fns-name">' + esc(s.label) + '</span><small>' + esc(pre + s.value.toLocaleString() + suf) + '</small></div></div>' +
          (i && showRates ? '<span class="fns-rate">&darr; 0%</span>' : '') +
          '</div>';
      }).join('');

      var rows = Array.prototype.slice.call(el.querySelectorAll('.fns-row'));
      var c = { el: el, stages: stages, rows: rows, k: stages.map(function () { return 0; }) };
      c.shapes = rows.map(function (r) { return r.querySelector('.fns-shape'); });
      c.texts = rows.map(function (r) { return r.querySelector('.fns-text'); });
      c.rates = rows.map(function (r) { return r.querySelector('.fns-rate'); });
      stages.forEach(function (s, i) {
        if (c.rates[i]) c.rates[i].style.left = 'calc((100% - ' + SIDE + 'px) * ' + (0.5 + s.w / 2).toFixed(4) + ' + 8px)';
      });
      el._fns = c;
      fit(c);
      if (window.ResizeObserver) new ResizeObserver(function () { fit(c); }).observe(el);
      return c;
    }

    // Drop the label (keep the value) where it won't fit in the narrow end.
    function fit(c) {
      var w = c.el.clientWidth - (c.el.style.getPropertyValue('--fns-side') === '0px' ? 0 : SIDE);
      c.texts.forEach(function (t, i) {
        var s = c.stages[i];
        var row = c.rows[i];
        row.classList.remove('fns-short');
        var need = t.children[0].offsetWidth + t.children[1].offsetWidth + 8;
        if (need > w * Math.min(s.w, s.wb) - 10) row.classList.add('fns-short');
      });
    }

    // Shape i at k (0–1): from a plain bar as wide as the stage above, to its trapezoid.
    function shape(c, i) {
      var s = c.stages[i], k = c.k[i];
      var start = i ? c.stages[i - 1].w : 1;
      var wt = start + (s.w - start) * k, wb = start + (s.wb - start) * k;
      var a = 50 - wt * 50, b = 50 + wt * 50, e = 50 + wb * 50, f = 50 - wb * 50;
      c.shapes[i].style.clipPath = 'polygon(' + a + '% 0,' + b + '% 0,' + e + '% 100%,' + f + '% 100%)';
    }

    function finish(c) {
      c.stages.forEach(function (s, i) {
        c.k[i] = 1; shape(c, i);
        c.rows[i].style.opacity = 1;
        c.texts[i].style.opacity = 1;
        if (c.rates[i]) { c.rates[i].style.opacity = 1; c.rates[i].innerHTML = '&darr; ' + pct(s.rate); }
      });
    }

    function play(c) {
      var d = c.el.dataset;
      var duration = +d.duration || DEFAULTS.duration;
      var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var tl = anime.createTimeline();
      c.stages.forEach(function (s, i) {
        var at = i * stagger;
        c.k[i] = 0; shape(c, i);
        anime.utils.set(c.texts[i], { opacity: 0 });
        tl.add(c.rows[i], { opacity: [0, 1], y: [-28, 0], duration: duration, ease: 'outBack(1.6)' }, at);
        var st = { k: 0 };
        tl.add(st, {
          k: 1, duration: duration, ease: 'out(3)',
          onUpdate: function () { c.k[i] = st.k; shape(c, i); }
        }, at + duration * 0.25);
        tl.add(c.texts[i], { opacity: [0, 1], duration: 300 }, at + duration * 0.6);
        var r = c.rates[i];
        if (r) {
          var n = { v: 0 };
          tl.add(r, { opacity: [0, 1], scale: [0.6, 1], duration: 450, ease: 'outBack(2)' }, at + duration * 0.7);
          tl.add(n, {
            v: s.rate, duration: 650, ease: 'out(3)',
            onUpdate: function () { r.innerHTML = '&darr; ' + pct(n.v); }
          }, at + duration * 0.7);
        }
      });
      if (d.loop !== undefined && d.loop !== 'false') {
        tl.then(function () {
          setTimeout(function () {
            anime.animate(c.rows.slice().reverse(), {
              opacity: 0, y: 16, duration: 380, ease: 'in(2)', delay: anime.stagger(70),
              onComplete: function () {
                c.rates.forEach(function (r) { if (r) r.style.opacity = 0; });
                play(c);
              }
            });
          }, DEFAULTS.hold);
        });
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target._fns);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-funnel-stages]').forEach(function (el) {
        if (el._fns) return;
        var c = setup(el);
        if (reduced()) { finish(c); return; }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-funnel-stages style="height: 260px">
  <span data-value="12400">Visited</span>
  <span data-value="5200">Signed up</span>
  <span data-value="2100">Trial</span>
  <span data-value="1240">Paid</span>
</div>