← SVG & icons

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

  1. Paste the snippet just before </head>.
  2. Add data-doodle-accents to a heading.
  3. 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-doodlesWhich doodles to draw, in order, from star, sparkle, swirl, arrow, circle and underline. Default star,sparkle,swirl,arrow.
data-colorPen colour, or several separated by commas, one per doodle. Default the text colour.
data-widthPen width. Default about a fifteenth of the font size.
data-durationTime to draw each doodle. Default 650 ms.
data-staggerGap between doodles starting. Default 380 ms.
data-loopFade and doodle again.
data-holdHow 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.

1 · Snippet
<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>
2 · Your HTML
<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>