← Text effects

Karaoke Highlight

Words fill with colour from left to right, one after another, like sing-along lyrics on a karaoke screen. Longer words take longer to sing, and a little ball can bounce along to keep the beat.

Every word right on time

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-karaoke-highlight to a line of text. The words start in the text’s own colour, so a soft one works best.
  3. To match a real track, give each word a start time with data-times.

Options

Add any of these attributes to the same element.

data-colorThe colour words fill with. Default #a855f7.
data-speedTime per letter, so longer words take longer. Default 85 ms.
data-gapPause between words. Default 140 ms.
data-timesYour own timing: when each word starts, in ms, separated by commas, e.g. data-times="0,500,1100".
data-ballA ball hops from word to word as each one starts.
data-loopClear the line and sing it again.
data-holdHow long the finished line stays before it starts again. Default 1400 ms.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Karaoke Highlight: words fill with colour from left to right in time, like
    // sing-along lyrics. Longer words take longer to sing, and a little ball can
    // hop from word to word to keep the beat.
    // Usage: <p data-karaoke-highlight>Sing along with me</p>
    // Optional: data-color (fill colour; default #a855f7), data-speed (ms per
    // letter; default 85), data-gap (pause between words, ms; default 140),
    // data-times (your own timing: when each word starts, in ms, comma-separated,
    // e.g. "0,500,1100,1400"), data-ball (a bouncing ball hops along the words),
    // data-loop (start again after a pause), data-hold (ms the sung line stays
    // before it starts again; default 1400).
    var DEFAULTS = { color: '#a855f7', speed: 85, gap: 140, hold: 1400 };

    var css =
      '.kar-word{position:relative;display:inline-block;white-space:nowrap}' +
      '.kar-fill{position:absolute;left:0;top:0;white-space:nowrap;pointer-events:none;clip-path:inset(-20% 100% -20% 0)}' +
      '.kar-ball{position:absolute;left:0;top:0;width:.2em;height:.2em;margin:-.1em 0 0 -.1em;border-radius:50%;pointer-events:none;opacity:0}';
    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, color) {
      var text = el.textContent.replace(/\s+/g, ' ').trim();
      el.setAttribute('aria-label', text);
      el.textContent = '';
      var words = [];
      text.split(' ').forEach(function (w, i) {
        if (i) el.appendChild(document.createTextNode(' '));
        var word = document.createElement('span');
        word.className = 'kar-word';
        word.setAttribute('aria-hidden', 'true');
        word.textContent = w;
        // A coloured copy sits exactly on top and is uncovered from the left.
        var fill = document.createElement('span');
        fill.className = 'kar-fill';
        fill.textContent = w;
        fill.style.color = color;
        word.appendChild(fill);
        el.appendChild(word);
        words.push({ el: word, fill: fill, len: Array.from(w).length });
      });
      return words;
    }

    function setup(el) {
      var d = el.dataset;
      var color = d.color || DEFAULTS.color;
      var speed = +d.speed || DEFAULTS.speed;
      var gap = d.gap !== undefined ? +d.gap : DEFAULTS.gap;
      var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
      var loop = d.loop !== undefined && d.loop !== 'false';
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
      var words = split(el, color);

      // When each word starts and how long it takes to fill.
      var custom = d.times ? d.times.split(',').map(Number) : null;
      var t = 0;
      words.forEach(function (w, i) {
        w.start = custom && !isNaN(custom[i]) ? custom[i] : t;
        w.dur = w.len * speed;
        t = w.start + w.dur + gap;
      });
      words.forEach(function (w, i) {
        // With custom times, a word fills until the next one starts (or its own length).
        if (custom && words[i + 1]) w.dur = Math.max(120, Math.min(w.dur * 2, words[i + 1].start - w.start - 40));
      });
      var last = words[words.length - 1];
      var sung = last.start + last.dur;
      var fade = 450;
      var cycle = sung + hold + fade + 500;

      if (reduced()) {
        words.forEach(function (w) { w.fill.style.clipPath = 'inset(-20% 0 -20% 0)'; });
        return;
      }

      var ball = null;
      if (d.ball !== undefined && d.ball !== 'false') {
        ball = document.createElement('span');
        ball.className = 'kar-ball';
        ball.setAttribute('aria-hidden', 'true');
        ball.style.background = color;
        el.appendChild(ball);
      }

      function measure() {
        words.forEach(function (w) {
          w.x = w.el.offsetLeft + w.el.offsetWidth / 2;
          w.y = w.el.offsetTop;
        });
      }
      measure();
      new ResizeObserver(measure).observe(el);

      function render(time) {
        var fadeOut = time > sung + hold ? Math.min(1, (time - sung - hold) / fade) : 0;
        var restart = time > sung + hold + fade;
        words.forEach(function (w) {
          var p = restart ? 0 : Math.max(0, Math.min(1, (time - w.start) / w.dur));
          w.fill.style.clipPath = 'inset(-20% ' + ((1 - p) * 100).toFixed(2) + '% -20% 0)';
          w.fill.style.opacity = restart ? 1 : 1 - fadeOut;
        });
        if (!ball) return;
        // The ball lands on each word as it starts, hopping in an arc between them.
        var fs = parseFloat(getComputedStyle(el).fontSize);
        var k = -1;
        for (var i = 0; i < words.length; i++) if (time >= words[i].start - 260) k = i;
        var a, b, f;
        if (k < 0) { a = b = words[0]; f = 1; }
        else {
          a = words[k];
          b = words[k + 1] || a;
          var from = a.start, to = b === a ? a.start + a.dur : b.start;
          f = Math.max(0, Math.min(1, (time - from) / Math.max(1, to - from)));
        }
        var x = a.x + (b.x - a.x) * f;
        var baseY = a.y + (b.y - a.y) * f - fs * 0.12;
        var hop = b === a ? 0 : Math.sin(Math.PI * f) * fs * (b.y !== a.y ? 0.2 : 0.35);
        var alpha = time < 60 ? time / 60 : 1;
        if (time > sung) alpha = Math.max(0, 1 - (time - sung) / 400);
        // Going down a line, it fades out and back in rather than crossing the text.
        if (b.y !== a.y) {
          alpha *= Math.abs(1 - 2 * f);
          x = f < 0.5 ? a.x : b.x;
          baseY = (f < 0.5 ? a.y : b.y) - fs * 0.12;
        }
        ball.style.opacity = alpha;
        ball.style.transform = 'translate(' + x.toFixed(1) + 'px,' + (baseY - hop).toFixed(1) + 'px)';
      }
      render(-1);

      var clock = 0, prev = 0, done = false;
      var ticker = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          clock += prev ? Math.min(50, now - prev) : 16;
          prev = now;
          if (!loop && clock >= sung) { render(sung); done = true; ticker.pause(); return; }
          if (clock >= cycle) clock -= cycle;
          render(clock);
        }
      });

      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting && !done) { prev = 0; ticker.play(); }
        else ticker.pause();
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-karaoke-highlight]').forEach(function (el) {
        if (el._kar) return;
        el._kar = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p data-karaoke-highlight data-ball data-loop style="color: #c4b5d9">Sing along with me</p>

<!-- timed to a track: -->
<p data-karaoke-highlight data-times="0,450,900,1600">Never gonna stop</p>