Streak Flame
A flickering flame sits over a streak counter. The number rolls up a day at a time and the flame grows with each tick, flaring as every new day lands, until it reaches the full streak and bursts with sparks. Embers drift up from it all the while.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-streak-flame data-to="7"></div>with the streak indata-to. Size it withfont-size. - It counts up when it scrolls into view. Call
animacious.streakFlame(el, 8)to count on to a new day.
Options
Add any of these attributes to the same element.
data-to | The streak to count up to. Default 7. |
data-from | Where the count starts. The flame starts small here and is full size at data-to. Default 0. |
data-label | The words after the number. Default “day streak”. |
data-step | Time per tick. Default 380 ms. |
data-color | Colour of the flame. Default #f97316. |
data-core | Colour of its hot centre and the sparks. Default #fde047. |
data-loop | Die down after a pause and count up again. |
data-hold | Pause before looping. Default 2400 ms. |
The flame only flickers while it’s on screen. Screen readers hear the final streak, such as “7 day streak”. With reduced motion it shows the full streak and a still flame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Streak Flame: a flickering flame with a streak counter. The number rolls
// up a day at a time and the flame grows with every tick, flaring as each
// new day lands, until it reaches the full streak and bursts with sparks.
// Usage: <div data-streak-flame data-to="7"></div> (size it with font-size)
// Optional: data-to (the streak to count up to, default 7), data-from
// (where the count starts, default 0), data-label (the words after the
// number, default "day streak"), data-step (ms per tick, default 380),
// data-color (flame colour, default #f97316), data-core (the hot inner
// colour, default #fde047), data-loop (die down after a pause and count
// up again), data-hold (ms before looping, default 2400).
// Script use: animacious.streakFlame(el, 8) counts on to 8.
var DEFAULTS = { to: 7, from: 0, label: 'day streak', step: 380, color: '#f97316', core: '#fde047', hold: 2400 };
var OUTER = 'M20 2c2 8 9 12.5 12.5 19.5C36 28.6 36 36.2 32 42c-3 4.4-7.4 7-12 7s-9-2.6-12-7c-4-5.8-4-13.4-.5-20.5 1.5-3 3.7-5 5.5-6.6.4 3.2 1.6 5.6 3.5 7 .5-7.4 1.5-13.8 3.5-19.9z';
var INNER = 'M20 22c1.3 4.4 5.5 7 7 11 1.6 4.4.2 9.2-3 11.6-1.2.9-2.6 1.4-4 1.4s-2.8-.5-4-1.4c-3.2-2.4-4.6-7.2-3-11.6.7-2 2-3.3 3-4.4.4 2 1.2 3.2 2.2 3.8.2-3.6.8-7 1.8-10.4z';
var uid = 0;
var css =
'.sfl{display:inline-flex;flex-direction:column;align-items:center;gap:.2em;line-height:1}' +
'.sfl-fire{position:relative;width:2.6em;height:3.4em;display:grid;place-items:end center}' +
'.sfl-flame{position:relative;width:100%;height:100%;transform-origin:50% 92%}' +
'.sfl-glow{position:absolute;left:50%;bottom:0;width:4em;height:4em;margin-left:-2em;border-radius:50%;' +
'background:radial-gradient(closest-side,color-mix(in srgb,var(--sfl-c) 45%,transparent),transparent);opacity:.4;transform-origin:50% 75%}' +
'.sfl-flame svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.sfl-o{transform-box:fill-box;transform-origin:50% 100%;animation:sfl-flick 1.1s ease-in-out infinite alternate}' +
'.sfl-i{transform-box:fill-box;transform-origin:50% 100%;animation:sfl-flick2 .7s ease-in-out infinite alternate}' +
'.sfl-paused .sfl-o,.sfl-paused .sfl-i,.sfl-paused .sfl-ember{animation-play-state:paused}' +
'.sfl-ember{position:absolute;bottom:40%;width:.16em;height:.16em;border-radius:50%;background:var(--sfl-k);opacity:0;animation:sfl-rise 1.6s ease-out infinite}' +
'.sfl-count{display:flex;align-items:baseline;gap:.3em;white-space:nowrap}' +
'.sfl-digits{position:relative;display:inline-grid;overflow:hidden;font-size:2em;font-weight:800;letter-spacing:-.03em;font-variant-numeric:tabular-nums;padding:.04em 0}' +
'.sfl-digits span{grid-area:1/1;justify-self:center}' +
'.sfl-label{font-weight:700;opacity:.65}' +
'.sfl-spark{position:absolute;left:50%;top:45%;width:.18em;height:.18em;margin:-.09em;border-radius:50%;background:var(--sfl-k);opacity:0}' +
'@keyframes sfl-flick{0%{transform:scale(1,1) skewX(0)}35%{transform:scale(.97,1.04) skewX(2deg)}70%{transform:scale(1.02,.97) skewX(-2deg)}100%{transform:scale(.98,1.05) skewX(1deg)}}' +
'@keyframes sfl-flick2{0%{transform:scale(1,1) skewX(0)}50%{transform:scale(.92,1.1) skewX(-4deg)}100%{transform:scale(1.04,.94) skewX(3deg)}}' +
'@keyframes sfl-rise{0%{opacity:0;transform:translate(0,0) scale(1)}15%{opacity:1}100%{opacity:0;transform:translate(var(--sfl-dx),-2.4em) scale(.3)}}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function opts(el) {
var d = el.dataset;
var from = Math.max(0, Math.round(+d.from || DEFAULTS.from));
var to = Math.max(from + 1, Math.round(d.to !== undefined ? +d.to : DEFAULTS.to));
return { from: from, to: to };
}
function setup(el) {
var d = el.dataset, o = opts(el);
var id = 'sfl-g' + (++uid);
var label = d.label || DEFAULTS.label;
el.classList.add('sfl');
el.style.setProperty('--sfl-c', d.color || DEFAULTS.color);
el.style.setProperty('--sfl-k', d.core || DEFAULTS.core);
el.setAttribute('role', 'img');
el.setAttribute('aria-label', o.to + ' ' + label);
var embers = '';
for (var i = 0; i < 5; i++) {
embers += '<span class="sfl-ember" style="left:' + (30 + i * 10) + '%;--sfl-dx:' + ((i % 2 ? 1 : -1) * (0.2 + i * 0.1)) +
'em;animation-delay:' + (i * 0.33).toFixed(2) + 's"></span>';
}
var sparks = '';
for (var j = 0; j < 8; j++) sparks += '<span class="sfl-spark"></span>';
el.innerHTML =
'<span class="sfl-fire" aria-hidden="true"><span class="sfl-glow"></span><span class="sfl-flame">' +
'<svg viewBox="0 0 40 52"><defs><linearGradient id="' + id + '" x1="0" y1="1" x2="0" y2="0">' +
'<stop offset="0" stop-color="' + (d.color || DEFAULTS.color) + '"/><stop offset="1" style="stop-color:color-mix(in srgb,' +
(d.color || DEFAULTS.color) + ' 70%,#ef4444)"/></linearGradient></defs>' +
'<path class="sfl-o" d="' + OUTER + '" fill="url(#' + id + ')"/>' +
'<path class="sfl-i" d="' + INNER + '" fill="' + (d.core || DEFAULTS.core) + '"/></svg>' + embers +
'</span>' + sparks + '</span>' +
'<span class="sfl-count" aria-hidden="true"><span class="sfl-digits"><span></span></span><span class="sfl-label"></span></span>';
el.querySelector('.sfl-label').textContent = label;
var s = el._sfl = {
flame: el.querySelector('.sfl-flame'), glow: el.querySelector('.sfl-glow'),
digits: el.querySelector('.sfl-digits'), sparks: el.querySelectorAll('.sfl-spark'),
n: reduced() ? o.to : o.from, from: o.from, to: o.to, timer: null, visible: false, pending: null, run: 0
};
s.digits.firstChild.textContent = s.n;
anime.utils.set(s.flame, { scale: size(s, s.n) });
if (reduced()) el.classList.add('sfl-paused');
}
// The flame is small for a new streak and full size at the target.
function size(s, n) { return 0.45 + 0.55 * Math.min(1, (n - s.from) / Math.max(1, s.to - s.from)); }
// Roll the number: the old one slides up and out, the new one rises in.
function roll(s, n, dur) {
var old = s.digits.lastChild;
var nu = document.createElement('span');
nu.textContent = n;
s.digits.appendChild(nu);
anime.animate(old, { y: ['0%', '-100%'], opacity: [1, 0], duration: dur, ease: 'in(2)', onComplete: function () { old.remove(); } });
anime.animate(nu, { y: ['100%', '0%'], opacity: [0, 1], duration: dur, ease: 'out(3)' });
}
function later(s, fn, ms) {
clearTimeout(s.timer);
s.timer = setTimeout(function () { if (s.visible) fn(); else s.pending = fn; }, ms);
}
function tick(el, done) {
var s = el._sfl, d = el.dataset;
var step = +d.step || DEFAULTS.step;
s.n++;
roll(s, s.n, Math.min(320, step * 0.8));
var sc = size(s, s.n);
anime.animate(s.flame, { scale: [{ to: sc * 1.14, duration: 140, ease: 'out(2)' }, { to: sc, duration: 520, ease: 'outElastic(1, .5)' }] });
anime.animate(s.glow, { opacity: [{ to: 0.9, duration: 120 }, { to: 0.4 + 0.4 * sc, duration: 600 }], scale: sc * 1.1, duration: 700 });
if (s.n < s.to) { later(s, function () { tick(el, done); }, step); return; }
// Reached the target: one big flare and a shower of sparks.
setTimeout(function () {
anime.animate(s.flame, { scale: [{ to: 1.25, duration: 180, ease: 'out(2)' }, { to: 1, duration: 800, ease: 'outElastic(1, .4)' }] });
anime.animate(s.digits, { scale: [{ to: 1.25, duration: 160 }, { to: 1, duration: 600, ease: 'outElastic(1, .5)' }] });
s.sparks.forEach(function (sp, i) {
var a = -Math.PI / 2 + (i / (s.sparks.length - 1) - 0.5) * 2.4;
var r = anime.utils.random(1.6, 2.4, 2);
anime.animate(sp, {
x: ['0em', Math.cos(a) * r + 'em'], y: ['0em', Math.sin(a) * r + 'em'],
opacity: [{ to: 1, duration: 60 }, { to: 0, duration: 500, delay: 250 }], scale: [1.2, 0.3],
duration: 800, ease: 'out(3)'
});
});
if (done) done();
}, step * 0.6);
}
function play(el) {
var s = el._sfl, d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
tick(el, loop ? function () {
later(s, function () {
// The fire dies down and the count resets.
anime.animate(s.flame, { scale: size(s, s.from), duration: 700, ease: 'inOut(2)' });
anime.animate(s.glow, { opacity: 0.4, scale: 1, duration: 700 });
s.n = s.from;
roll(s, s.n, 320);
later(s, function () { play(el); }, 1100);
}, +d.hold || DEFAULTS.hold);
} : null);
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var el = e.target, s = el._sfl;
s.visible = e.isIntersecting;
el.classList.toggle('sfl-paused', !s.visible);
if (!s.visible) return;
if (!s.started) { s.started = true; later(s, function () { play(el); }, 600); }
else if (s.pending) { var fn = s.pending; s.pending = null; fn(); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-streak-flame]').forEach(function (el) {
if (el._sfl) return;
setup(el);
if (!reduced()) io.observe(el);
});
}
window.animacious = window.animacious || {};
window.animacious.streakFlame = function (el, to) {
var s = el._sfl;
if (!s || !(to > s.n)) return;
s.to = Math.round(to);
el.setAttribute('aria-label', s.to + ' ' + (el.dataset.label || DEFAULTS.label));
if (reduced()) { s.n = s.to; s.digits.lastChild.textContent = s.n; return; }
tick(el, null);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-streak-flame data-to="7" style="font-size: 24px"></div>
<!-- carrying on from yesterday, in blue: -->
<div data-streak-flame data-from="29" data-to="30" data-color="#3b82f6" data-core="#bae6fd"></div>