Firework Message
A rocket climbs into the night sky and bursts, but instead of a round shower its sparks fly out to spell a word. The letters glitter for a moment, then droop and fade like real fireworks before the next word goes up. Click to launch one early.
Click or tap to launch
How to use it
- Paste the snippet just before
</head>. - Add
data-firework-messageto a section with a dark background, and put the words indata-text, separated by commas. - The words are drawn in the middle of the section, so keep your own content near the edges.
Options
Add any of these attributes to the same element.
data-text | The words to spell, comma-separated, taken in turn. Default Hooray. |
data-loop | Keep going round the words. Without it, each one goes up once. |
data-hold | How long each word stays lit. Default 1600 ms. |
data-gap | Space between the sparks in the letters, in px. Default 5. |
data-colors | Comma-separated colours, one per word in turn. |
Short words read best on a phone; long ones are shrunk to fit the width. It’s drawn on a canvas behind your content and paused when off screen. If the section has no text of its own, the words become its aria-label. With reduced motion the first word is shown lit and still. From code, animacious.fireworkMessage(section, "Thanks!") launches a word.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Firework Message: a rocket climbs from the bottom of a section and bursts,
// and instead of a round shower its sparks fly out to spell a word in the
// sky. The word glitters for a moment, then the sparks droop and fade like
// real fireworks and the next word goes up. Drawn on a canvas behind your
// content, and paused when off screen.
// Usage: <section data-firework-message data-text="Happy,New Year">…</section>
// Optional: data-text (comma-separated words or short phrases, taken in
// turn; default "Hooray"), data-colors (comma-separated, one per word in
// turn), data-hold (ms each word stays lit, default 1600), data-gap (px
// between sparks in the letters, default 5), data-loop (keep going; without
// it each word goes up once). Click inside to launch the next word.
// Script use: animacious.fireworkMessage(element, 'Text') launches a word.
var DEFAULTS = { text: 'Hooray', hold: 1600, gap: 5, colors: ['#facc15', '#f472b6', '#38bdf8', '#a78bfa', '#4ade80', '#fb923c'] };
var FONT = '800 {s}px system-ui, -apple-system, "Segoe UI", sans-serif';
var css =
'[data-firework-message]{position:relative;isolation:isolate;overflow:hidden}' +
'.fwm-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;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; }
// Sample the pixels of a word drawn as big as fits, and return points
// (relative to the word's centre) every `gap` px.
function wordPoints(text, w, h, gap) {
var maxW = w * 0.86, maxH = h * 0.34;
var c = document.createElement('canvas');
var x = c.getContext('2d');
var size = Math.min(maxH, 160);
x.font = FONT.replace('{s}', size);
var tw = x.measureText(text).width;
if (tw > maxW) { size *= maxW / tw; x.font = FONT.replace('{s}', size); tw = x.measureText(text).width; }
var cw = Math.ceil(tw + 8), ch = Math.ceil(size * 1.3);
c.width = cw; c.height = ch;
x.font = FONT.replace('{s}', size);
x.textAlign = 'center';
x.textBaseline = 'middle';
x.fillStyle = '#000';
x.fillText(text, cw / 2, ch / 2);
var data = x.getImageData(0, 0, cw, ch).data;
var pts = [];
var g = Math.max(2, gap * Math.min(1, size / 70));
for (var y = 0; y < ch; y += g) {
for (var xx = 0; xx < cw; xx += g) {
var jx = xx + (Math.random() - 0.5) * g * 0.5, jy = y + (Math.random() - 0.5) * g * 0.5;
var i = ((Math.round(jy) * cw + Math.round(jx)) * 4) + 3;
if (data[i] > 128) pts.push({ x: jx - cw / 2, y: jy - ch / 2, s: g });
}
}
return pts;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'fwm-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var words = (d.text || DEFAULTS.text).split(',').map(function (s) { return s.trim(); }).filter(Boolean);
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
var gap = +d.gap || DEFAULTS.gap;
var loop = d.loop !== undefined && d.loop !== 'false';
if (!el.hasAttribute('aria-label') && !el.textContent.trim()) el.setAttribute('aria-label', words.join(', '));
var w = 0, h = 0, dpr = 1, unit = 1;
var rockets = [], sparks = [], next = 0, wait = 300, launched = 0, colorIndex = 0;
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
unit = Math.max(0.4, h / 700);
canvas.width = w * dpr;
canvas.height = h * dpr;
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
function launch(text) {
if (!w || !h) return;
if (text === undefined) { text = words[next % words.length]; next++; }
var color = colors[colorIndex++ % colors.length];
var tx = w / 2 + (Math.random() - 0.5) * w * 0.08, ty = h * 0.42;
var g = 0.0009 * unit;
var vy = -Math.sqrt(2 * g * (h - ty));
var t = -vy / g;
var x = w / 2 + (Math.random() - 0.5) * w * 0.2;
rockets.push({ x: x, y: h, vx: (tx - x) / t, vy: vy, g: g, color: color, text: text, tx: tx, ty: ty, trail: [] });
launched++;
}
// The burst: every spark is assigned a point in the word and flies there
// on an ease-out curve, overshooting the round shape of a normal burst.
function burst(r) {
var pts = wordPoints(r.text, w, h, gap);
var life = hold + 1400;
pts.forEach(function (p) {
var tx = r.tx + p.x, ty = r.ty + p.y;
sparks.push({
sx: r.x, sy: r.y, tx: tx, ty: ty, x: r.x, y: r.y, vx: 0, vy: 0,
age: -Math.random() * 120, fly: 650 + Math.random() * 350, life: life + Math.random() * 500,
color: Math.random() < 0.18 ? '#ffffff' : r.color, tw: Math.random() * 6.28, word: true, s: Math.max(1.6, p.s * 0.55)
});
});
// A few loose sparks round the edge, so it still reads as a firework.
for (var i = 0; i < 46; i++) {
var a = Math.random() * Math.PI * 2, s = (0.12 + Math.random() * 0.2) * unit;
sparks.push({ x: r.x, y: r.y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, age: 0, life: 900 + Math.random() * 600, color: r.color, tw: 0, word: false });
}
}
function step(dt) {
wait -= dt;
if (wait <= 0 && (loop || launched < words.length)) { launch(); wait = hold + 2600; }
for (var i = rockets.length - 1; i >= 0; i--) {
var r = rockets[i];
r.trail.push(r.x, r.y);
if (r.trail.length > 16) r.trail.splice(0, 2);
r.x += r.vx * dt;
r.y += r.vy * dt;
r.vy += r.g * dt;
if (r.vy >= -0.02) { burst(r); rockets.splice(i, 1); }
}
var g = 0.00018 * unit;
for (var j = sparks.length - 1; j >= 0; j--) {
var p = sparks[j];
p.age += dt;
if (p.age < 0) continue;
if (p.word) {
if (p.age < p.fly) {
var k = p.age / p.fly, e = 1 - Math.pow(1 - k, 4);
p.x = p.sx + (p.tx - p.sx) * e;
p.y = p.sy + (p.ty - p.sy) * e;
} else if (p.age > p.life - 1200) {
// Let go: drift down under gravity as it fades.
p.vy += g * dt;
p.vx *= 0.995;
p.x += p.vx * dt;
p.y += p.vy * dt;
} else {
p.x = p.tx + Math.sin(p.age * 0.004 + p.tw) * 0.5;
p.y = p.ty + Math.cos(p.age * 0.003 + p.tw) * 0.5;
}
} else {
p.vy += g * dt;
p.vx *= 0.99; p.vy *= 0.99;
p.x += p.vx * dt;
p.y += p.vy * dt;
}
if (p.age > p.life) sparks.splice(j, 1);
}
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.globalCompositeOperation = 'lighter';
rockets.forEach(function (r) {
for (var i = 0; i < r.trail.length; i += 2) {
ctx.globalAlpha = (i / r.trail.length) * 0.8;
ctx.fillStyle = r.color;
ctx.fillRect(r.trail[i] - 1, r.trail[i + 1] - 1, 2, 2);
}
ctx.globalAlpha = 1;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(r.x, r.y, 2.2, 0, 6.283);
ctx.fill();
});
for (var j = 0; j < sparks.length; j++) {
var p = sparks[j];
if (p.age < 0) continue;
var fade = Math.min(1, (p.life - p.age) / 1000);
var tw = p.word && p.age > p.fly ? 0.65 + 0.35 * Math.sin(p.age * 0.012 + p.tw * 3) : 1;
ctx.globalAlpha = Math.max(0, fade * tw);
ctx.fillStyle = p.color;
var s = p.word ? p.s : 1.6;
if (p.word && p.age < p.fly) {
// A short streak while it's flying into place.
ctx.strokeStyle = p.color;
ctx.lineWidth = s;
ctx.beginPath();
var k = Math.max(0, (p.age - 40) / p.fly), e = 1 - Math.pow(1 - k, 4);
ctx.moveTo(p.sx + (p.tx - p.sx) * e, p.sy + (p.ty - p.sy) * e);
ctx.lineTo(p.x, p.y);
ctx.stroke();
} else {
ctx.fillRect(p.x - s / 2, p.y - s / 2, s, s);
// A faint halo, so the letters glow.
ctx.globalAlpha *= 0.18;
ctx.fillRect(p.x - s * 1.5, p.y - s * 1.5, s * 3, s * 3);
}
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
el._fwmLaunch = function (text) { launch(text); wait = Math.max(wait, hold + 2600); };
if (reduced()) {
// A still frame: the first word, lit.
var pts = wordPoints(words[0], w, h, gap);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = colors[0];
pts.forEach(function (p) { var s0 = Math.max(1.6, p.s * 0.55); ctx.fillRect(w / 2 + p.x - s0 / 2, h * 0.42 + p.y - s0 / 2, s0, s0); });
return;
}
el.addEventListener('click', function () { el._fwmLaunch(); });
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(40, now - last) : 16;
last = now;
step(dt);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
window.animacious = window.animacious || {};
window.animacious.fireworkMessage = function (el, text) { if (el && el._fwmLaunch) el._fwmLaunch(text); };
function init() {
document.querySelectorAll('[data-firework-message]').forEach(function (el) {
if (el._fwm) return;
el._fwm = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-firework-message data-loop data-text="Happy,New Year" style="background: #0b0b1a">
</section>