← Diagrams & maps

Choropleth Fill

The regions of a map fill with colour in a sweep across it, deeper shades for bigger values, so the pattern appears as you watch. A legend shows the scale underneath, and hovering or tapping a region shows its name and value.

AK ME WI VT NH WA ID MT ND MN IL MI NY MA OR NV WY SD IA IN OH PA NJ CT RI CA UT CO NE MO KY WV VA MD DE AZ NM KS AR TN NC SC DC OK LA MS AL GA HI TX FL

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-choropleth-fill to a container holding your map, usually an inline SVG.
  3. Give each region data-value and, for the tip, data-name. Regions can be SVG shapes or groups, or plain HTML boxes.

Options

Add any of these attributes to the same element.

data-valueOn a region: its number.
data-nameOn a region: its name, shown in the tip.
data-colorsColours for the lowest and highest values. Default #ede9fe,#5b21b6.
data-stepsHow many colour classes. Default 5; 0 for a smooth scale.
data-min / data-maxEnds of the scale. Default: the lowest and highest values.
data-labelTitle for the legend.
data-unitShown after values, such as %.
data-ordervalue fills from lowest to highest. Default: sweeps left to right.
data-durationTime for the whole sweep. Default 1600 ms.
data-loopDrain the colour and fill again.

On an SVG <g> region, its first shape is filled and any text inside turns light or dark to stay readable. The numbers in the demo are made up.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Choropleth Fill: the regions of a map fill with colour one after
    // another, darker for bigger values, sweeping across the map. A legend
    // shows the scale, and hovering or tapping a region shows its value.
    // Usage:
    //   <div data-choropleth-fill data-label="Rainfall (mm)">
    //     <svg viewBox="0 0 400 300">
    //       <path data-value="42" data-name="North" d="…"/>
    //       <path data-value="87" data-name="South" d="…"/>
    //     </svg>
    //   </div>
    // Every element with data-value is a region: SVG shapes, or HTML boxes.
    // On an SVG <g>, its first shape is filled and any text inside turns
    // light or dark to stay readable.
    // Optional: data-colors (low and high colour, default #ede9fe,#5b21b6),
    // data-steps (number of colour classes, default 5; 0 for a smooth
    // scale), data-min / data-max (scale ends, default the data's range),
    // data-label (legend title), data-unit (after values, e.g. "%"),
    // data-order ("value" fills low to high; default sweeps left to right),
    // data-duration (ms for the whole sweep, default 1600), data-loop
    // (drain and fill again), data-name on a region (its name in the tip).
    var DEFAULTS = { colors: '#ede9fe,#5b21b6', steps: 5, duration: 1600 };
    var EMPTY = '#ece8f3';

    var css =
      '.cpl{position:relative;line-height:1.2}' +
      '.cpl svg{display:block;width:100%;height:auto;overflow:visible}' +
      '.cpl [data-value]{transition:fill .55s ease,background-color .55s ease,opacity .3s}' +
      '.cpl text{transition:fill .55s ease;pointer-events:none}' +
      '.cpl-hot{opacity:.75}' +
      '.cpl-legend{display:flex;align-items:center;gap:.6em;margin-top:.9em;font-size:.82em;color:#6b6280}' +
      '.cpl-legend b{font-weight:700;color:#2a1d40;margin-right:.2em}' +
      '.cpl-bar{display:flex;flex:1 1 auto;max-width:14em;height:.7em;border-radius:99px;overflow:hidden}' +
      '.cpl-bar i{flex:1}' +
      '.cpl-tip{position:absolute;z-index:2;transform:translate(-50%,-115%);padding:.3em .65em;border-radius:.5em;background:#1f1530;color:#fff;font-size:.82em;font-weight:700;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .2s}' +
      '.cpl-tip.cpl-on{opacity:1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function rgb(hex) {
      var h = hex.trim().replace('#', '');
      if (h.length === 3) h = h.replace(/./g, '$&$&');
      var n = parseInt(h, 16);
      return [n >> 16 & 255, n >> 8 & 255, n & 255];
    }
    function mix(a, b, t) {
      return 'rgb(' + a.map(function (v, i) { return Math.round(v + (b[i] - v) * t); }).join(',') + ')';
    }
    function light(c) {
      var m = /(\d+),(\d+),(\d+)/.exec(c);
      return m && (0.299 * m[1] + 0.587 * m[2] + 0.114 * m[3]) > 150;
    }
    function num(v) { return Math.abs(v) >= 100 || v % 1 === 0 ? Math.round(v).toString() : v.toFixed(1); }

    function setup(el) {
      var d = el.dataset;
      var s = (el._cpl = { regions: [] });
      el.classList.add('cpl');
      var cols = (d.colors || DEFAULTS.colors).split(',');
      var lo = rgb(cols[0]), hi = rgb(cols[cols.length - 1]);
      var steps = d.steps !== undefined ? +d.steps : DEFAULTS.steps;
      var nodes = Array.from(el.querySelectorAll('[data-value]'));
      var vals = nodes.map(function (n) { return parseFloat(n.getAttribute('data-value')); });
      var min = d.min !== undefined ? +d.min : Math.min.apply(null, vals);
      var max = d.max !== undefined ? +d.max : Math.max.apply(null, vals);
      var unit = d.unit || '';
      function colorFor(v) {
        var t = max > min ? Math.max(0, Math.min(1, (v - min) / (max - min))) : 1;
        if (steps > 1) t = Math.min(steps - 1, Math.floor(t * steps)) / (steps - 1);
        return mix(lo, hi, t);
      }
      nodes.forEach(function (n, i) {
        var svg = n instanceof SVGElement;
        var target = svg && n.tagName.toLowerCase() === 'g' ? n.querySelector('path,rect,circle,ellipse,polygon') : n;
        var r = {
          node: n, target: target, svg: svg, value: vals[i], color: colorFor(vals[i]),
          texts: svg ? Array.from(n.querySelectorAll('text')) : [],
          name: n.getAttribute('data-name') || n.getAttribute('aria-label') || (n.querySelector('title') || {}).textContent || '',
        };
        s.regions.push(r);
        paint(r, EMPTY);
        if (svg && r.name) {
          n.setAttribute('role', 'img');
          n.setAttribute('aria-label', r.name + ': ' + num(r.value) + unit);
        }
      });
      // Legend.
      var legend = document.createElement('div');
      legend.className = 'cpl-legend';
      var bar = '<span class="cpl-bar" aria-hidden="true">';
      var n = steps > 1 ? steps : 12;
      for (var k = 0; k < n; k++) bar += '<i style="background:' + mix(lo, hi, k / (n - 1)) + '"></i>';
      legend.innerHTML = (d.label ? '<b></b>' : '') + '<span>' + num(min) + unit + '</span>' + bar + '</span><span>' + num(max) + unit + '</span>';
      if (d.label) legend.firstChild.textContent = d.label;
      el.appendChild(legend);
      tips(el, unit);
    }

    function paint(r, color) {
      if (r.svg) r.target.style.fill = color;
      else r.target.style.backgroundColor = color;
      r.texts.forEach(function (t) { t.style.fill = color === EMPTY || light(color) ? '#2a1d40' : '#fff'; });
    }

    function tips(el, unit) {
      var s = el._cpl;
      if (el.closest('[aria-hidden="true"]')) return;
      var tip = document.createElement('div');
      tip.className = 'cpl-tip';
      tip.setAttribute('aria-hidden', 'true');
      el.appendChild(tip);
      var hot = null;
      function show(r) {
        if (hot) hot.node.classList.remove('cpl-hot');
        hot = r;
        if (!r) { tip.classList.remove('cpl-on'); return; }
        r.node.classList.add('cpl-hot');
        var box = r.node.getBoundingClientRect(), me = el.getBoundingClientRect();
        tip.textContent = (r.name ? r.name + ' · ' : '') + num(r.value) + unit;
        tip.style.left = Math.max(40, Math.min(me.width - 40, box.left + box.width / 2 - me.left)) + 'px';
        tip.style.top = (box.top - me.top) + 'px';
        tip.classList.add('cpl-on');
      }
      s.regions.forEach(function (r) {
        r.node.addEventListener('pointerenter', function () { show(r); });
        r.node.addEventListener('pointerdown', function () { show(r); });
        r.node.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') show(null); });
      });
      el.addEventListener('pointerleave', function () { show(null); });
    }

    function order(el) {
      var s = el._cpl;
      if (el.dataset.order === 'value') return s.regions.slice().sort(function (a, b) { return a.value - b.value; });
      return s.regions.map(function (r) {
        var b = r.node.getBoundingClientRect();
        return { r: r, k: b.left + b.top * 0.35 };
      }).sort(function (a, b) { return a.k - b.k; }).map(function (o) { return o.r; });
    }

    function fill(el) {
      var s = el._cpl, list = order(el), dur = +el.dataset.duration || DEFAULTS.duration;
      s.timers = [];
      list.forEach(function (r, i) {
        s.timers.push(setTimeout(function () { paint(r, r.color); }, i / Math.max(1, list.length - 1) * dur));
      });
      if (el.dataset.loop === undefined) return;
      setTimeout(function () {
        list.slice().reverse().forEach(function (r, i) {
          setTimeout(function () { paint(r, EMPTY); }, i / Math.max(1, list.length - 1) * dur * 0.5);
        });
        setTimeout(function () { fill(el); }, dur * 0.5 + 900);
      }, dur + 3200);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          setTimeout(function () { fill(e.target); }, 300);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-choropleth-fill]').forEach(function (el) {
        if (el._cpl) return;
        setup(el);
        if (reduced()) el._cpl.regions.forEach(function (r) { paint(r, r.color); });
        else io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-choropleth-fill data-label="Rainfall" data-unit=" mm">
  <svg viewBox="0 0 400 300">
    <path data-value="42" data-name="North" d="M0 0H200V150H0Z"/>
    <path data-value="87" data-name="East" d="M200 0H400V150H200Z"/>
    <path data-value="15" data-name="South" d="M0 150H400V300H0Z"/>
  </svg>
</div>