← Text reveals

Hand-drawn Box

A rough marker box scribbles itself around a phrase, one crooked side at a time, each stroke running a little past its corner, as if someone boxed it with a felt-tip.

Read the fine print

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap the word or short phrase in <span data-hand-drawn-box>.

Options

Add any of these attributes to the same element.

data-shapesingle (default) or double, a second, looser pass over the first.
data-colorThe marker. Default #a855f7.
data-durationTime to draw the box. Default 1000 ms.
data-loopWipe it off and draw it again.
data-holdHow long it stays, when looping. Default 1800 ms.
data-delayWait before starting. Default 0 ms.

The box stretches to fit and keeps the phrase on one line, so keep it short.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Hand-drawn Box: a rough marker box scribbles itself around a phrase, one
    // crooked side at a time, each stroke running a little past the corner.
    // Usage: <p>Read <span data-hand-drawn-box>the fine print</span></p>
    // Optional: data-color (the marker, default "#a855f7"), data-shape
    // ("single" or "double", a second, looser pass over the first),
    // data-duration (ms for the whole box, default 1000), data-loop (wipe it
    // off and draw again), data-hold (ms to stay, when looping, default 1800),
    // data-delay (ms).
    var DEFAULTS = { color: '#a855f7', duration: 1000, hold: 1800, delay: 0 };
    // Drawn in a 100 x 40 box stretched round the phrase: top, right, bottom,
    // left, each a slightly bowed line that overshoots its corner.
    var SIDES = [
      'M-1.5 5 C 28 3.4, 66 4.6, 101.5 2.4',
      'M99.4 -1.5 C 100.4 12, 99 26, 100.2 41.5',
      'M102 37.2 C 68 38.6, 30 36.4, -1.8 38.4',
      'M0.6 42 C -0.6 28, 1.2 13, -0.2 0.5'
    ];
    var SECOND = [
      'M2 2.6 C 34 4.8, 70 1.8, 99.5 5.2',
      'M98.6 1 C 99.8 14, 101.2 27, 99 39.6',
      'M100 39.4 C 64 36.6, 28 39.8, 1.2 36.4',
      'M1.4 39.8 C 1 26, -0.6 14, 1.2 3'
    ];

    var css =
      '.hdbx{position:relative;display:inline-block;white-space:nowrap;padding:0 .22em}' +
      '.hdbx-svg{position:absolute;left:0;top:-.08em;width:100%;height:calc(100% + .2em);overflow:visible;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() {
      return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    }

    function build(el) {
      var d = el.dataset;
      var paths = d.shape === 'double' ? SIDES.concat(SECOND) : SIDES;
      var width = Math.max(2, parseFloat(getComputedStyle(el).fontSize) * 0.065);
      el.classList.add('hdbx');
      var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
      svg.setAttribute('class', 'hdbx-svg');
      svg.setAttribute('aria-hidden', 'true');
      svg.innerHTML = paths.map(function (p, i) {
        return '<path fill="none" stroke="' + (d.color || DEFAULTS.color) + '" stroke-width="' +
          (width * (i > 3 ? 0.6 : 1)).toFixed(1) + '" stroke-opacity="' + (i > 3 ? 0.7 : 0.92) +
          '" stroke-linecap="round"/>';
      }).join('');
      el.appendChild(svg);
      var s = { svg: svg, src: paths, paths: Array.from(svg.querySelectorAll('path')) };
      fit(s);
      return s;
    }

    // Scale the sides from their 100 x 40 box to the phrase's real size in
    // pixels, so every stroke keeps an even thickness and draws its full length.
    function fit(s) {
      var r = s.svg.getBoundingClientRect();
      var w = Math.max(1, r.width), h = Math.max(1, r.height);
      s.svg.setAttribute('viewBox', '0 0 ' + w.toFixed(1) + ' ' + h.toFixed(1));
      s.paths.forEach(function (p, k) {
        var i = 0;
        p.setAttribute('d', s.src[k].replace(/-?\d*\.?\d+/g, function (n) {
          return (+n * (i++ % 2 ? h / 40 : w / 100)).toFixed(1);
        }));
      });
      s.strokes = s.paths.map(function (p) { return anime.svg.createDrawable(p)[0]; });
    }

    function play(el) {
      var s0 = el._hdbx, d = el.dataset;
      fit(s0);
      var strokes = s0.strokes;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var duration = +d.duration || DEFAULTS.duration;
      var tl = anime.createTimeline({
        onComplete: function () { if (loop) setTimeout(function () { play(el); }, 350); }
      });
      anime.utils.set(strokes, { draw: '0 0' });
      // Long sides take longer than short ones; a marker lifts briefly between strokes.
      var sides = [0.34, 0.16, 0.34, 0.16];
      var t = 0;
      strokes.forEach(function (s, i) {
        var len = duration * sides[i % 4] * (i > 3 ? 0.7 : 1);
        tl.add(s, { draw: ['0 0', '0 1'], duration: len, ease: 'inOut(1.4)' }, t);
        t += len + 40;
      });
      if (loop) {
        tl.add(strokes, { draw: '1 1', duration: 420, delay: anime.stagger(60), ease: 'inOut(2)' }, t + (+d.hold || DEFAULTS.hold));
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          var el = e.target;
          setTimeout(function () { play(el); }, +el.dataset.delay || DEFAULTS.delay);
        });
      }, { threshold: 0.5 });
      document.querySelectorAll('[data-hand-drawn-box]').forEach(function (el) {
        if (el._hdbx) return;
        var s = el._hdbx = build(el);
        if (reduced()) return;
        anime.utils.set(s.strokes, { draw: '0 0' });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p>Read <span data-hand-drawn-box>the fine print</span></p>

<!-- or, twice round, in red: -->
<span data-hand-drawn-box data-shape="double" data-color="#e11d48">Boxed in</span>