Comic Pop Text
The words burst out of a spiky yellow starburst like a sound effect in a comic strip: thick black outlines, halftone dots round the edge, letters popping in at jaunty angles and a little shake as they land.
KA-BOOM!
How to use it
- Paste the snippet just before
</head>. - Add
data-comic-pop-textto a short, punchy word or two. Leave some room around it for the starburst.
Options
Add any of these attributes to the same element.
data-loop | Pop away and burst back in, forever. |
data-hold | How long the text stays between pops when looping. Default 1800 ms. |
data-color | Letter colour. Default #ff2e63. |
data-burst | Starburst colour. Default #ffd60a; none for just the letters. |
data-dots | Halftone dot colour. Default #ff8a00. |
data-points | How many spikes the starburst has. Default 14. |
The starburst reaches about 0.8em past the text on each side, so keep it to a word or two.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Comic Pop Text: the text bursts out of a spiky comic-book starburst, with
// a thick black outline, halftone dots and a little shake, like a sound
// effect in a comic strip.
// Usage: <h1 data-comic-pop-text>KA-POW!</h1>
// Optional: data-loop (pop away and burst back in, forever), data-hold (ms
// the text stays between pops when looping; default 1800), data-color
// (letter colour; default #ff2e63), data-burst (starburst colour; default
// #ffd60a; "none" for no burst), data-dots (halftone dot colour; default
// #ff8a00), data-points (number of spikes; default 14).
var DEFAULTS = { hold: 1800, color: '#ff2e63', burst: '#ffd60a', dots: '#ff8a00', points: 14 };
var css =
'.cmp-wrap{position:relative;display:inline-block;isolation:isolate}' +
'.cmp-word{display:inline-block;white-space:nowrap}' +
'.cmp-ch{display:inline-block;color:var(--cmp-c);-webkit-text-stroke:.075em #111;paint-order:stroke fill;text-shadow:.05em .07em 0 #111;will-change:transform}' +
'.cmp-burst{position:absolute;left:-.8em;right:-.8em;top:-.7em;bottom:-.7em;z-index:-1;pointer-events:none;filter:drop-shadow(.06em .08em 0 #111)}' +
'.cmp-burst>span{position:absolute;inset:0}' +
'.cmp-burst>.cmp-fill,.cmp-burst>.cmp-dots{inset:.07em}' +
'.cmp-edge{background:#111}' +
'.cmp-fill{background:var(--cmp-b)}' +
'.cmp-dots{background-image:radial-gradient(circle,var(--cmp-d) 34%,transparent 38%);background-size:.17em .17em;' +
'-webkit-mask-image:radial-gradient(closest-side,transparent 35%,#000 95%);mask-image:radial-gradient(closest-side,transparent 35%,#000 95%)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function split(el) {
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.textContent = '';
var wrap = document.createElement('span');
wrap.className = 'cmp-wrap';
wrap.setAttribute('aria-hidden', 'true');
var chars = [];
text.split(' ').forEach(function (word, i) {
if (i) wrap.appendChild(document.createTextNode(' '));
var w = document.createElement('span');
w.className = 'cmp-word';
Array.from(word).forEach(function (c) {
var s = document.createElement('span');
s.className = 'cmp-ch';
s.textContent = c;
w.appendChild(s);
chars.push(s);
});
wrap.appendChild(w);
});
el.appendChild(wrap);
return { wrap: wrap, chars: chars };
}
// A spiky star as a clip-path polygon, a little uneven like it was inked by hand.
function starPath(points) {
var pts = [], turn = Math.random() * Math.PI / points;
for (var i = 0; i < points * 2; i++) {
var a = turn + i * Math.PI / points - Math.PI / 2;
var r = i % 2 ? anime.utils.random(0.6, 0.72, 3) : anime.utils.random(0.9, 1, 3);
pts.push((50 + 50 * r * Math.cos(a)).toFixed(1) + '% ' + (50 + 50 * r * Math.sin(a)).toFixed(1) + '%');
}
return 'polygon(' + pts.join(',') + ')';
}
function setup(el) {
var d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var hold = +d.hold || DEFAULTS.hold;
var points = Math.max(6, +d.points || DEFAULTS.points);
var parts = split(el);
var wrap = parts.wrap, chars = parts.chars;
wrap.style.setProperty('--cmp-c', d.color || DEFAULTS.color);
wrap.style.setProperty('--cmp-b', d.burst || DEFAULTS.burst);
wrap.style.setProperty('--cmp-d', d.dots || DEFAULTS.dots);
var burst = null, layers = [];
if (d.burst !== 'none') {
burst = document.createElement('span');
burst.className = 'cmp-burst';
['cmp-edge', 'cmp-fill', 'cmp-dots'].forEach(function (cls) {
var s = document.createElement('span');
s.className = cls;
burst.appendChild(s);
layers.push(s);
});
wrap.insertBefore(burst, wrap.firstChild);
}
function reshape() {
var p = starPath(points);
layers.forEach(function (l) { l.style.clipPath = p; l.style.webkitClipPath = p; });
}
reshape();
if (reduced()) return;
var visible = false, waiting = false, running = false;
function hide() {
anime.utils.set(chars, { scale: 0 });
if (burst) anime.utils.set(burst, { scale: 0 });
}
function popIn() {
running = true;
reshape();
anime.utils.set(chars, { scale: 0, y: '.35em', rotate: function () { return anime.utils.random(-40, 40); } });
var tl = anime.createTimeline({ onComplete: done });
if (burst) {
tl.add(burst, {
scale: [{ from: 0, to: 1.18, duration: 260, ease: 'out(3)' }, { to: 1, duration: 260, ease: 'inOut(2)' }],
rotate: { from: -24, to: 0, duration: 520, ease: 'outBack(2)' }
}, 0);
}
tl.add(chars, {
scale: { to: 1, duration: 480, ease: 'outBack(3)' },
rotate: { to: function () { return anime.utils.random(-6, 6); }, duration: 520, ease: 'outBack(2)' },
y: { to: '0em', duration: 420, ease: 'out(3)' },
delay: anime.stagger(45)
}, burst ? 110 : 0);
// The shake once everything has landed.
tl.add(wrap, {
x: [{ to: '-.07em' }, { to: '.07em' }, { to: '-.05em' }, { to: '.04em' }, { to: '-.02em' }, { to: '0em' }],
rotate: [{ to: -1.5 }, { to: 1.5 }, { to: -1 }, { to: 0.5 }, { to: 0 }],
duration: 360,
ease: 'linear'
}, 140 + chars.length * 45 + 200);
}
function done() {
if (!loop) { running = false; return; }
setTimeout(function () {
var tl = anime.createTimeline({
onComplete: function () {
setTimeout(function () {
running = false;
if (visible) popIn(); else waiting = true;
}, 450);
}
});
tl.add(chars, { scale: 0, rotate: function () { return anime.utils.random(-30, 30); }, duration: 260, ease: 'in(3)', delay: anime.stagger(25, { from: 'last' }) }, 0);
if (burst) tl.add(burst, { scale: 0, rotate: 20, duration: 300, ease: 'inBack(2)' }, 80);
}, hold);
}
hide();
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && !running && (waiting || !el._cmpPlayed)) {
waiting = false;
el._cmpPlayed = true;
popIn();
}
}, { threshold: 0.2 }).observe(el);
}
function init() {
document.querySelectorAll('[data-comic-pop-text]').forEach(function (el) {
if (el._cmp) return;
el._cmp = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-comic-pop-text>KA-BOOM!</h1>
<!-- looping, blue letters on a white burst: -->
<h1 data-comic-pop-text data-loop data-color="#2563eb" data-burst="#fff">ZAP!</h1>