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
- Paste the snippet just before
</head>. - Wrap the word or short phrase in
<span data-hand-drawn-box>.
Options
Add any of these attributes to the same element.
data-shape | single (default) or double, a second, looser pass over the first. |
data-color | The marker. Default #a855f7. |
data-duration | Time to draw the box. Default 1000 ms. |
data-loop | Wipe it off and draw it again. |
data-hold | How long it stays, when looping. Default 1800 ms. |
data-delay | Wait before starting. Default 0 ms. |
The box stretches to fit and keeps the phrase on one line, so keep it short.
<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><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>