← Text effects

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

  1. Paste the snippet just before </head>.
  2. Add data-comic-pop-text to 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-loopPop away and burst back in, forever.
data-holdHow long the text stays between pops when looping. Default 1800 ms.
data-colorLetter colour. Default #ff2e63.
data-burstStarburst colour. Default #ffd60a; none for just the letters.
data-dotsHalftone dot colour. Default #ff8a00.
data-pointsHow 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.

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