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
- Paste the snippet just before
</head>. - Add
data-outline-to-fillto an icon’s<svg>. - Give its shapes a
strokefor the outline and afillfor the colour they fill with. Withfill="none"they fill with the stroke colour.
Options
Add any of these attributes to the same element.
data-duration | Time to draw the outline. Default 1000 ms. |
data-fill-duration | Time to fill up. Default 1300 ms. |
data-delay | Wait before it starts. Handy for a row of icons. |
data-wave="false" | A flat surface instead of a wave. |
data-loop | Fill, hold, drain and repeat. |
data-hold | How long it stays full, when looping. Default 1600 ms. |
It starts when scrolled into view. With reduced motion the icon shows filled.
<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><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>