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
- Paste the snippet just before
</head>. - Add
data-scribble-fillto an inline<svg>. - Each filled shape is scribbled in with its
fillcolour, and itsstroke, if it has one, draws first as the outline. Mark shapes withdata-scribbleto fill only those.
Options
Add any of these attributes to the same element.
data-angle | Slant of the pen strokes. Default -35°. |
data-gap | Space between strokes, in viewBox units. Default about a twelfth of the shape. |
data-pen | Width of the pen. Default 1.2 times the gap. |
data-duration | Time to fill each shape. Default 1400 ms. |
data-stagger | Gap between shapes. Default 250 ms. |
data-loop | Fill, hold, fade and scribble again. |
data-hold | How long it shows, when looping. Default 2000 ms. |
data-delay | Wait 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.
<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><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>