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
- Paste the snippet just before
</head>. - Add
data-karaoke-highlightto a line of text. The words start in the text’s own colour, so a soft one works best. - 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-color | The colour words fill with. Default #a855f7. |
data-speed | Time per letter, so longer words take longer. Default 85 ms. |
data-gap | Pause between words. Default 140 ms. |
data-times | Your own timing: when each word starts, in ms, separated by commas, e.g. data-times="0,500,1100". |
data-ball | A ball hops from word to word as each one starts. |
data-loop | Clear the line and sing it again. |
data-hold | How long the finished line stays before it starts again. Default 1400 ms. |
<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><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>