← SVG & icons

Outline to Fill

An icon draws its outline, then fills up with colour like liquid poured into it, a gentle wave rolling on the surface as it rises. Loop it and the colour drains away before it starts again.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-outline-to-fill to an icon’s <svg>.
  3. Give its shapes a stroke for the outline and a fill for the colour they fill with. With fill="none" they fill with the stroke colour.

Options

Add any of these attributes to the same element.

data-durationTime to draw the outline. Default 1000 ms.
data-fill-durationTime to fill up. Default 1300 ms.
data-delayWait before it starts. Handy for a row of icons.
data-wave="false"A flat surface instead of a wave.
data-loopFill, hold, drain and repeat.
data-holdHow long it stays full, when looping. Default 1600 ms.

It starts when scrolled into view. With reduced motion the icon shows filled.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Outline to Fill: an icon draws its outline, then fills up with colour
    // like liquid poured into it, a gentle wave on the surface as it rises.
    // Loop it and the colour drains away and the outline wipes off before it
    // starts again. Starts when scrolled into view.
    // Usage: give the shapes a stroke for the outline and a fill for the
    // colour they fill with (fill="none" fills with the stroke colour):
    //   <svg data-outline-to-fill viewBox="0 0 24 24" fill="#f9a8d4" stroke="#db2777" stroke-width="1.5">
    //     <path d="…heart…"/>
    //   </svg>
    // Optional: data-duration (ms to draw the outline, default 1000),
    // data-fill-duration (ms to fill, default 1300), data-delay (ms before it
    // starts), data-wave="false" (a flat surface), data-loop (fill, hold,
    // drain and repeat), data-hold (ms it stays full when looping, default 1600).
    var DEFAULTS = { duration: 1000, fill: 1300, hold: 1600 };
    var SHAPES = 'path, line, polyline, polygon, circle, ellipse, rect';
    var NS = 'http://www.w3.org/2000/svg';
    var count = 0;

    function setup(svg) {
      var d = svg.dataset;
      var shapes = Array.from(svg.querySelectorAll(SHAPES)).filter(function (s) { return !s.closest('defs, clipPath, mask'); });
      if (!shapes.length) return null;
      var vb = svg.viewBox.baseVal;
      var box = vb && vb.width ? { x: vb.x, y: vb.y, w: vb.width, h: vb.height } : { x: 0, y: 0, w: svg.clientWidth, h: svg.clientHeight };
      // The liquid: a wavy-topped block, used as a clip on filled copies of the
      // shapes. It is two wavelengths wider than the icon, so it can slide
      // sideways forever while rising.
      var id = 'otf-clip-' + ++count;
      var amp = box.h * 0.045, len = box.w * 0.6;
      var wave = 'M' + (box.x - len * 2) + ' 0';
      for (var x = box.x - len * 2; x < box.x + box.w + len; x += len) {
        wave += ' q' + len / 4 + ' ' + -amp + ' ' + len / 2 + ' 0 t' + len / 2 + ' 0';
      }
      wave += ' V' + box.h * 3 + ' H' + (box.x - len * 2) + ' Z';
      if (d.wave === 'false') wave = 'M' + (box.x - len * 2) + ' 0 H' + (box.x + box.w + len * 2) + ' V' + box.h * 3 + ' H' + (box.x - len * 2) + ' Z';
      var defs = document.createElementNS(NS, 'defs');
      defs.innerHTML = '<clipPath id="' + id + '"><path d="' + wave + '"/></clipPath>';
      svg.insertBefore(defs, svg.firstChild);
      var liquid = defs.querySelector('path');
      var fills = shapes.map(function (s) {
        var cs = getComputedStyle(s);
        var color = cs.fill && cs.fill !== 'none' ? cs.fill : cs.stroke;
        var f = s.cloneNode(false);
        f.setAttribute('fill', color);
        f.setAttribute('stroke', 'none');
        f.style.fill = color;
        f.style.stroke = 'none';
        f.setAttribute('clip-path', 'url(#' + id + ')');
        f.setAttribute('aria-hidden', 'true');
        // The colour sits under the outline.
        s.parentNode.insertBefore(f, s);
        s.style.fill = 'none';
        return f;
      });
      var o = {
        svg: svg, box: box, amp: amp, len: len, liquid: liquid, fills: fills,
        lines: anime.svg.createDrawable(shapes), state: { level: 0, shift: 0 }
      };
      render(o);
      anime.utils.set(o.lines, { draw: '0 0' });
      return o;
    }

    // level 0: the surface is just below the icon; level 1: just above it.
    function render(o) {
      var b = o.box;
      var y = b.y + b.h + o.amp - o.state.level * (b.h + o.amp * 3);
      o.liquid.setAttribute('transform', 'translate(' + o.state.shift * o.len + ' ' + y + ')');
    }

    function run(o) {
      var d = o.svg.dataset;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var fillTime = +d.fillDuration || DEFAULTS.fill;
      var draw = function () { render(o); };
      // The surface keeps sliding sideways; it only shows while it's filling.
      var slide = anime.animate(o.state, { shift: [0, 1], duration: 1100, ease: 'linear', loop: true, onUpdate: draw });
      var tl = anime.createTimeline({ loop: loop, delay: +d.delay || 0, onComplete: function () { slide.pause(); } });
      tl.add(o.lines, { draw: ['0 0', '0 1'], duration: duration, ease: 'inOut(3)', delay: anime.stagger(150) });
      tl.add(o.state, { level: [0, 1], duration: fillTime, ease: 'inOut(1.6)', onUpdate: draw }, '-=' + duration * 0.15);
      if (loop) {
        var hold = +d.hold || DEFAULTS.hold;
        tl.add(o.state, { level: 0, duration: fillTime * 0.6, ease: 'in(2)', onUpdate: draw }, '+=' + hold);
        tl.add(o.lines, { draw: ['0 1', '1 1'], duration: duration * 0.7, ease: 'inOut(3)', delay: anime.stagger(100) }, '-=' + fillTime * 0.2);
      }
      // Pause the slide whenever it's off screen.
      new IntersectionObserver(function (entries) {
        if (!entries[0].isIntersecting) { slide.pause(); tl.pause(); } else { tl.play(); if (!tl.completed) slide.play(); }
      }).observe(o.svg);
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          run(e.target._otf);
        });
      }, { threshold: 0.4 });
      document.querySelectorAll('[data-outline-to-fill]').forEach(function (svg) {
        if (svg._otf || reduced) return;
        svg._otf = setup(svg);
        if (svg._otf) io.observe(svg);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<svg data-outline-to-fill viewBox="0 0 24 24" fill="#f472b6" stroke="#be185d" stroke-width="1.4">
  <path d="M12 20.5 C12 20.5 3 15 3 8.8 C3 6 5.2 3.8 8 3.8 C9.7 3.8 11.2 4.7 12 6 C12.8 4.7 14.3 3.8 16 3.8 C18.8 3.8 21 6 21 8.8 C21 15 12 20.5 12 20.5 Z"/>
</svg>