← SVG & icons

Scribble Fill

A shape’s outline draws itself, then a loose pen scribble zigzags back and forth inside it until it’s coloured in, like a quick marker sketch. Every run scribbles a little differently.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-scribble-fill to an inline <svg>.
  3. Each filled shape is scribbled in with its fill colour, and its stroke, if it has one, draws first as the outline. Mark shapes with data-scribble to fill only those.

Options

Add any of these attributes to the same element.

data-angleSlant of the pen strokes. Default -35°.
data-gapSpace between strokes, in viewBox units. Default about a twelfth of the shape.
data-penWidth of the pen. Default 1.2 times the gap.
data-durationTime to fill each shape. Default 1400 ms.
data-staggerGap between shapes. Default 250 ms.
data-loopFill, hold, fade and scribble again.
data-holdHow long it shows, when looping. Default 2000 ms.
data-delayWait before it starts.

The scribble is clipped to the shape, so loose ends never stray outside it. It starts when scrolled into view. With reduced motion the shapes simply show, filled.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Scribble Fill: a shape's outline draws itself, then a loose pen scribble
    // zigzags back and forth inside it until it's coloured in, like a quick
    // marker sketch. Every run scribbles a little differently.
    // Usage:
    //   <svg data-scribble-fill viewBox="0 0 100 100" stroke="#1f1530" stroke-width="3">
    //     <path d="…" fill="#f43f5e"/>
    //   </svg>
    // Each shape's fill becomes its scribble colour; its stroke (if any) is
    // drawn first as the outline. Mark shapes with data-scribble to fill only
    // those; otherwise every filled shape is scribbled in.
    // Optional: data-angle (slant of the strokes in degrees, default -35),
    // data-gap (space between strokes, in viewBox units, default about a
    // twelfth of the shape), data-pen (pen width, default 1.2 times the gap),
    // data-duration (ms to fill each shape, default 1400), data-stagger (ms
    // between shapes, default 250), data-loop (fill, hold, fade and repeat),
    // data-hold (ms it shows when looping, default 2000), data-delay.
    var DEFAULTS = { angle: -35, duration: 1400, stagger: 250, hold: 2000 };
    var NS = 'http://www.w3.org/2000/svg';
    var SHAPES = 'path, circle, ellipse, rect, polygon';
    var uid = 0;

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function rnd(a, b) { return a + Math.random() * (b - a); }

    function make(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      if (parent) parent.appendChild(n);
      return n;
    }

    // Where the line through (px, py) in direction (ux, uy) crosses the box:
    // the range of s for which (px + ux*s, py + uy*s) is inside it.
    function span(box, px, py, ux, uy) {
      var lo = -Infinity, hi = Infinity;
      [[px, ux, box.x, box.x + box.width], [py, uy, box.y, box.y + box.height]].forEach(function (k) {
        if (Math.abs(k[1]) < 1e-6) {
          if (k[0] < k[2] || k[0] > k[3]) { lo = Infinity; hi = -Infinity; }
          return;
        }
        var a = (k[2] - k[0]) / k[1], b = (k[3] - k[0]) / k[1];
        lo = Math.max(lo, Math.min(a, b));
        hi = Math.min(hi, Math.max(a, b));
      });
      return lo <= hi ? [lo, hi] : null;
    }

    // A zigzag of slightly bowed strokes that covers the box (x, y, w, h),
    // slanted by angle, in the shape's own coordinates. Each stroke runs just
    // past the box's edges; the shape clips it.
    function scribble(box, angle, gap) {
      var a = (angle * Math.PI) / 180;
      var ux = Math.cos(a), uy = Math.sin(a); // along a stroke
      var vx = -uy, vy = ux;                  // across the strokes
      var cx = box.x + box.width / 2, cy = box.y + box.height / 2;
      var across = (Math.abs(vx) * box.width + Math.abs(vy) * box.height) / 2;
      var d = '', n = Math.ceil((across * 2) / gap), prev = null;
      for (var i = 0; i <= n; i++) {
        var off = -across + (i / n) * across * 2 + rnd(-0.2, 0.2) * gap;
        var px = cx + vx * off, py = cy + vy * off;
        var r = span(box, px, py, ux, uy);
        if (!r) continue;
        var over = gap * rnd(0.15, 0.45);
        var reach = i % 2 ? r[1] + over : r[0] - over;
        var x = px + ux * reach, y = py + uy * reach;
        if (!prev) { d = 'M' + x.toFixed(1) + ',' + y.toFixed(1); }
        else {
          // Bow each stroke a little, like a hand sweeping across.
          var mx = (prev[0] + x) / 2 + vx * gap * rnd(-0.5, 0.5), my = (prev[1] + y) / 2 + vy * gap * rnd(-0.5, 0.5);
          d += ' Q' + mx.toFixed(1) + ',' + my.toFixed(1) + ' ' + x.toFixed(1) + ',' + y.toFixed(1);
        }
        prev = [x, y];
      }
      return d;
    }

    function setup(svg) {
      var d = svg.dataset;
      var marked = svg.querySelectorAll('[data-scribble]');
      var list = Array.prototype.filter.call(marked.length ? marked : svg.querySelectorAll(SHAPES), function (s) {
        if (s.closest('defs, clipPath, mask, .scf-ink')) return false;
        var fill = getComputedStyle(s).fill;
        return marked.length || (fill && fill !== 'none');
      });
      if (!list.length) return null;
      var id = 'scf-' + ++uid;
      var defs = make('defs', {});
      svg.insertBefore(defs, svg.firstChild);
      var items = list.map(function (s, i) {
        var cs = getComputedStyle(s);
        var color = s.dataset.color || (cs.fill !== 'none' ? cs.fill : 'currentColor');
        var hasOutline = cs.stroke !== 'none' && parseFloat(cs.strokeWidth) > 0;
        var clip = make('clipPath', { id: id + '-' + i }, defs);
        var shape = s.cloneNode(false);
        shape.removeAttribute('id');
        shape.removeAttribute('transform');
        clip.appendChild(shape);
        // The ink sits under the outline, in the shape's own coordinates.
        var g = make('g', { class: 'scf-ink', 'clip-path': 'url(#' + id + '-' + i + ')', 'aria-hidden': 'true' });
        if (s.getAttribute('transform')) g.setAttribute('transform', s.getAttribute('transform'));
        s.parentNode.insertBefore(g, s);
        var ink = make('path', { fill: 'none', stroke: color, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, g);
        s.style.fill = 'none';
        var L = hasOutline ? s.getTotalLength() : 0;
        return { shape: s, ink: ink, outline: hasOutline, L: L, off: L + (parseFloat(cs.strokeWidth) || 0) };
      });
      return { svg: svg, items: items };
    }

    // A fresh scribble for every run, so it never looks stamped.
    function prepare(o) {
      var d = o.svg.dataset;
      var angle = d.angle !== undefined ? +d.angle : DEFAULTS.angle;
      o.items.forEach(function (it) {
        var box = it.shape.getBBox();
        var gap = +d.gap || Math.max(1.5, Math.min(box.width, box.height) / 12);
        var path = scribble(box, angle + rnd(-6, 6), gap);
        it.ink.setAttribute('d', path);
        it.ink.setAttribute('stroke-width', +d.pen || gap * 1.2);
        it.inkL = it.ink.getTotalLength();
        // Hidden dashes sit a pen's width past the end, so no round cap peeks out.
        it.inkOff = it.inkL + (+d.pen || gap * 1.2);
      });
    }

    function show(o, done) {
      o.items.forEach(function (it) {
        it.ink.style.strokeDasharray = it.inkL + ' ' + it.inkL * 2;
        it.ink.style.strokeDashoffset = done ? 0 : it.inkOff;
        it.ink.style.opacity = 1;
        if (it.outline) {
          it.shape.style.strokeDasharray = it.L + ' ' + it.L * 2;
          it.shape.style.strokeDashoffset = done ? 0 : it.off;
        }
      });
    }

    function run(o) {
      var d = o.svg.dataset;
      var dur = +d.duration || DEFAULTS.duration;
      var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var hold = +d.hold || DEFAULTS.hold;
      var draw = function () {
        prepare(o);
        show(o, false);
        var tl = anime.createTimeline({ onComplete: function () { if (loop) setTimeout(fade, hold); } });
        o.items.forEach(function (it, i) {
          var at = i * (stagger + (it.outline ? 500 : 0));
          if (it.outline) tl.add(it.shape, { strokeDashoffset: [it.off, 0], duration: 700, ease: 'inOut(2)' }, at);
          tl.add(it.ink, { strokeDashoffset: [it.inkOff, 0], duration: dur, ease: 'inOut(1.4)' }, at + (it.outline ? 450 : 0));
        });
      };
      var fade = function () {
        var tl = anime.createTimeline({ onComplete: function () { setTimeout(draw, 250); } });
        tl.add(o.items.map(function (it) { return it.ink; }), { opacity: 0, duration: 500, ease: 'inOut(2)' }, 0);
        o.items.forEach(function (it) {
          if (it.outline) tl.add(it.shape, { strokeDashoffset: -it.off, duration: 600, ease: 'in(2)' }, 0);
        });
      };
      setTimeout(draw, +d.delay || 200);
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          run(e.target._scf);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-scribble-fill]').forEach(function (svg) {
        if (svg._scf) return;
        var o = setup(svg);
        svg._scf = o || true;
        if (!o) return;
        prepare(o);
        if (reduced()) { show(o, true); return; }
        show(o, false);
        io.observe(svg);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<svg data-scribble-fill viewBox="0 0 100 100" width="160"
     stroke="#1f1530" stroke-width="3" stroke-linejoin="round">
  <circle cx="50" cy="50" r="40" fill="#8b5cf6"/>
</svg>