Doodle Accents
Little pen doodles draw themselves round a heading one after another: a star, a few sparkle marks, a loopy underline and an arrow, as if someone had annotated it with a marker. Loop it and they fade and doodle again, a little differently each time.
Make it pop
How to use it
- Paste the snippet just before
</head>. - Add
data-doodle-accentsto a heading. - The doodles sit just outside the words, so leave about 1em of room round them.
Options
Add any of these attributes to the same element.
data-doodles | Which doodles to draw, in order, from star, sparkle, swirl, arrow, circle and underline. Default star,sparkle,swirl,arrow. |
data-color | Pen colour, or several separated by commas, one per doodle. Default the text colour. |
data-width | Pen width. Default about a fifteenth of the font size. |
data-duration | Time to draw each doodle. Default 650 ms. |
data-stagger | Gap between doodles starting. Default 380 ms. |
data-loop | Fade and doodle again. |
data-hold | How long they show, when looping. Default 2200 ms. |
The doodles are drawn to fit the words, and redrawn if the heading changes size. They’re hidden from screen readers. With reduced motion they simply show.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Doodle Accents: little pen doodles (a star, sparkle marks, a loopy
// underline, an arrow) draw themselves around a heading one after another,
// like someone annotating it with a marker.
// Usage: <h2 data-doodle-accents>Hand-made with care</h2>
// Optional: data-doodles (which doodles, in drawing order, from star,
// sparkle, swirl, arrow, circle, underline; default
// "star,sparkle,swirl,arrow"), data-color (one colour, or several separated
// by commas to take turns; default the text colour), data-width (pen width
// in px, default about a fifteenth of the font size), data-duration (ms per
// doodle, default 650), data-stagger (ms between doodles, default 380),
// data-loop (fade, then doodle again a little differently),
// data-hold (ms, when looping, default 2200).
// The doodles sit just outside the text, so leave about 1em clear round it.
var DEFAULTS = { doodles: 'star,sparkle,swirl,arrow', duration: 650, stagger: 380, hold: 2200 };
var SVGNS = 'http://www.w3.org/2000/svg';
var css = '.dda-in{position:relative;display:inline-block}' +
'.dda-svg{position:absolute;left:0;top:0;overflow:visible;pointer-events:none;max-width:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function f(n) { return n.toFixed(1); }
function wob(e) { return (Math.random() - 0.5) * e; }
// Each doodle is a path built from the text box: w and h in px, e = 1em in px.
// `j` is a little randomness so every run looks freshly drawn.
var DOODLES = {
star: function (w, h, e, j) {
var cx = -0.6 * e + j[0] * 0.1 * e, cy = -0.12 * e, R = 0.3 * e, r = R * 0.45, rot = -0.3 + j[1] * 0.3, d = '';
for (var i = 0; i <= 10; i++) {
var a = rot - Math.PI / 2 + i * Math.PI / 5, rr = i % 2 ? r : R;
// The pen overshoots the start a touch when it closes the star.
if (i === 10) rr *= 1.08;
d += (i ? 'L' : 'M') + f(cx + Math.cos(a) * rr) + ' ' + f(cy + Math.sin(a) * rr);
}
return d;
},
sparkle: function (w, h, e, j) {
var x = w + 0.1 * e, y = 0.05 * e, d = '';
[-1.45, -0.85, -0.25].forEach(function (a, i) {
a += j[i] * 0.12;
var r0 = 0.14 * e, r1 = (i === 1 ? 0.56 : 0.44) * e;
d += 'M' + f(x + Math.cos(a) * r0) + ' ' + f(y + Math.sin(a) * r0) + 'L' + f(x + Math.cos(a) * r1) + ' ' + f(y + Math.sin(a) * r1);
});
return d;
},
swirl: function (w, h, e, j) {
var y = h + 0.12 * e, lx = w * (0.55 + j[0] * 0.08), L = 0.15 * e;
return 'M' + f(-0.02 * w) + ' ' + f(y + 0.03 * e) +
'C' + f(w * 0.2) + ' ' + f(y - 0.06 * e) + ' ' + f(lx - 0.25 * w) + ' ' + f(y - 0.05 * e) + ' ' + f(lx) + ' ' + f(y) +
'C' + f(lx + 1.6 * L) + ' ' + f(y + 0.4 * L) + ' ' + f(lx + 1.1 * L) + ' ' + f(y + 2.2 * L) + ' ' + f(lx + 0.2 * L) + ' ' + f(y + 1.9 * L) +
'C' + f(lx - 0.8 * L) + ' ' + f(y + 1.6 * L) + ' ' + f(lx - 0.2 * L) + ' ' + f(y - 0.3 * L) + ' ' + f(lx + 1.4 * L) + ' ' + f(y - 0.1 * L) +
'C' + f(lx + 0.18 * w) + ' ' + f(y) + ' ' + f(w * 0.92) + ' ' + f(y - 0.02 * e) + ' ' + f(w * 1.03) + ' ' + f(y - 0.09 * e);
},
arrow: function (w, h, e, j) {
var tx = w + 0.62 * e, ty = h + 0.55 * e;
var hx = w + 0.14 * e, hy = h * 0.62 + j[0] * 0.05 * e;
var cx = w + 0.78 * e, cy = h * 0.5;
var bx = cx - hx, by = cy - hy, bl = Math.sqrt(bx * bx + by * by) || 1;
bx /= bl; by /= bl;
var s = 0.2 * e, p = 0.13 * e;
return 'M' + f(tx) + ' ' + f(ty) + 'Q' + f(cx) + ' ' + f(cy) + ' ' + f(hx) + ' ' + f(hy) +
'M' + f(hx + bx * s - by * p) + ' ' + f(hy + by * s + bx * p) + 'L' + f(hx) + ' ' + f(hy) +
'L' + f(hx + bx * s + by * p) + ' ' + f(hy + by * s - bx * p);
},
circle: function (w, h, e, j) {
var cx = w / 2, cy = h / 2, rx = w / 2 + 0.3 * e, ry = h / 2 + 0.22 * e, d = '';
var a0 = Math.PI * (0.92 + j[0] * 0.05), n = 48;
for (var i = 0; i <= n; i++) {
// A bit more than one lap, drifting outwards, like a quick pen loop.
var a = a0 + i / n * Math.PI * 2.15, k = 1 + i / n * 0.07;
d += (i ? 'L' : 'M') + f(cx + Math.cos(a) * rx * k) + ' ' + f(cy + Math.sin(a) * ry * k);
}
return d;
},
underline: function (w, h, e, j) {
var y = h + 0.1 * e;
return 'M' + f(-0.03 * w) + ' ' + f(y + 0.05 * e) + 'Q' + f(w * (0.5 + j[0] * 0.1)) + ' ' + f(y + 0.2 * e) + ' ' + f(w * 1.03) + ' ' + f(y - 0.02 * e);
}
};
function setup(el) {
var d = el.dataset;
var names = (d.doodles || DEFAULTS.doodles).split(',').map(function (s) { return s.trim(); })
.filter(function (s) { return DOODLES[s]; });
var colors = (d.color || 'currentColor').split(',');
var inner = document.createElement('span');
inner.className = 'dda-in';
while (el.firstChild) inner.appendChild(el.firstChild);
el.appendChild(inner);
var svg = document.createElementNS(SVGNS, 'svg');
svg.setAttribute('class', 'dda-svg');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
inner.appendChild(svg);
var paths = names.map(function (n, i) {
var p = document.createElementNS(SVGNS, 'path');
p.setAttribute('stroke', colors[i % colors.length].trim());
svg.appendChild(p);
return { name: n, el: p, j: [wob(1), wob(1), wob(1)] };
});
var o = { el: el, inner: inner, svg: svg, paths: paths };
shape(o);
if (window.ResizeObserver) new ResizeObserver(function () { shape(o); }).observe(inner);
return o;
}
// Build every doodle to fit the text as it is laid out now.
function shape(o) {
var w = o.inner.offsetWidth, h = o.inner.offsetHeight;
var e = parseFloat(getComputedStyle(o.el).fontSize) || 32;
o.svg.setAttribute('width', w);
o.svg.setAttribute('height', h);
o.svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
o.svg.setAttribute('stroke-width', o.el.dataset.width || f(Math.max(2, e * 0.065)));
o.paths.forEach(function (p) { p.el.setAttribute('d', DOODLES[p.name](w, h, e, p.j)); });
}
function hide(o) {
o.paths.forEach(function (p) { p.el.style.opacity = 1; });
anime.utils.set(o.lines, { draw: '0 0' });
}
function play(o) {
var d = o.el.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 !== undefined ? +d.hold : DEFAULTS.hold;
hide(o);
var a = anime.animate(o.lines, {
draw: ['0 0', '0 1'],
duration: dur,
delay: anime.stagger(stagger),
ease: 'inOut(2)'
});
if (!loop) return;
a.then(function () {
setTimeout(function () {
anime.animate(o.paths.map(function (p) { return p.el; }), { opacity: 0, duration: 400, ease: 'out(2)' }).then(function () {
// Scribble a slightly different set next time.
o.paths.forEach(function (p) { p.j = [wob(1), wob(1), wob(1)]; });
shape(o);
hide(o);
waitVisible(o, function () { play(o); });
});
}, hold);
});
}
function waitVisible(o, fn) {
if (o.visible) { setTimeout(fn, 250); return; }
o.onShow = fn;
}
function init() {
var still = reduced();
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var o = e.target._dda;
o.visible = e.isIntersecting;
if (!o.visible) return;
if (!o.started) { o.started = true; play(o); }
else if (o.onShow) { var fn = o.onShow; o.onShow = null; fn(); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-doodle-accents]').forEach(function (el) {
if (el._dda) return;
var o = setup(el);
el._dda = o;
if (still) return;
o.lines = anime.svg.createDrawable(o.paths.map(function (p) { return p.el; }));
hide(o);
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h2 data-doodle-accents data-color="#f59e0b,#ec4899,#8b5cf6">Make it pop</h2>
<!-- just a circle and an underline: -->
<h2 data-doodle-accents data-doodles="circle,underline">New</h2>