Campfire
A little night scene: flames lick and flicker over crossed logs, a warm glow pulses on the ground and sparks drift up into the dark under a sky full of stars. Cosy.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-campfire>where the scene should go. It fills the element with an SVG scene.
Options
Add any of these attributes to the same element.
data-sparks | Number of sparks in the air. Default 18. |
data-speed | Flicker speed multiplier. Default 1. |
data-stars="false" | No stars in the sky. |
It is 8:5 with a night-sky background by default; set your own height or background on the element to change that. It pauses when off screen. With reduced motion it shows one still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Campfire: a little night scene. Flames lick and flicker over crossed
// logs, a warm glow pulses on the ground and sparks drift up into the
// dark. Drawn as SVG that fills the element, and paused when off screen.
// Usage: <div data-campfire></div>
// It is 8:5 by default with a night-sky background; give it your own
// height or background to change that.
// Optional: data-sparks (number of sparks, default 18), data-speed
// (flicker speed multiplier, default 1), data-stars="false" (no stars).
var DEFAULTS = { sparks: 18, speed: 1 };
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
var css =
'[data-campfire]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5;' +
'background:radial-gradient(ellipse 70% 60% at 50% 100%,#3b1d4a 0%,#1a1033 55%,#0b0a1f 100%)}' +
'.cfr-svg{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:visible}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// A flame tongue standing on (0, 0): w is the half-width at the base,
// h its height, lean how far the tip sways sideways.
function flame(w, h, lean) {
var tx = lean, ty = -h;
return 'M' + (-w) + ' 0' +
'C' + (-w * 1.1) + ' ' + (-h * 0.38) + ' ' + (-w * 0.35 + lean * 0.4) + ' ' + (-h * 0.62) + ' ' + tx + ' ' + ty +
'C' + (w * 0.35 + lean * 0.4) + ' ' + (-h * 0.62) + ' ' + (w * 1.1) + ' ' + (-h * 0.38) + ' ' + w + ' 0' +
'Q0 ' + (w * 0.55) + ' ' + (-w) + ' 0Z';
}
function build(el) {
var d = el.dataset;
var n = ++uid;
var count = d.sparks !== undefined ? Math.max(0, +d.sparks) : DEFAULTS.sparks;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A campfire burning at night');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
var stars = '';
if (d.stars !== 'false') {
for (var i = 0; i < 70; i++) {
var sx = (Math.random() * 1400 - 500).toFixed(1), sy = (Math.random() * 230 - 120).toFixed(1);
stars += '<circle class="cfr-star" cx="' + sx + '" cy="' + sy + '" r="' + (Math.random() * 0.9 + 0.4).toFixed(2) + '" fill="#fff" opacity="' + (Math.random() * 0.5 + 0.3).toFixed(2) + '"/>';
}
}
var g = 'cfr' + n;
el.innerHTML =
'<svg class="cfr-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
'<defs>' +
'<radialGradient id="' + g + '-aura"><stop offset="0" stop-color="#fb923c" stop-opacity=".45"/><stop offset=".5" stop-color="#f97316" stop-opacity=".12"/><stop offset="1" stop-color="#f97316" stop-opacity="0"/></radialGradient>' +
'<radialGradient id="' + g + '-pool"><stop offset="0" stop-color="#fdba74" stop-opacity=".75"/><stop offset="1" stop-color="#ea580c" stop-opacity="0"/></radialGradient>' +
'<linearGradient id="' + g + '-log" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#92400e"/><stop offset="1" stop-color="#451a03"/></linearGradient>' +
'</defs>' +
stars +
'<circle cx="330" cy="44" r="24" fill="#fef3c7" opacity=".08"/><circle cx="330" cy="44" r="12" fill="#fef3c7" opacity=".9"/>' +
// Ground: wide enough to fill any width.
'<path d="M-800 214 Q200 196 1200 214 L1200 600 L-800 600Z" fill="#160d26"/>' +
'<circle class="cfr-aura" cx="200" cy="170" r="150" fill="url(#' + g + '-aura)"/>' +
'<ellipse class="cfr-pool" cx="200" cy="214" rx="110" ry="18" fill="url(#' + g + '-pool)"/>' +
'<g transform="translate(200 206)">' +
'<ellipse cx="0" cy="-2" rx="34" ry="7" fill="#f97316" opacity=".55"/>' +
'<g class="cfr-flames"></g>' +
'<g transform="rotate(-14)"><rect x="-56" y="-8" width="112" height="16" rx="8" fill="url(#' + g + '-log)"/><ellipse cx="54" cy="0" rx="5" ry="8" fill="#d6a46b"/><ellipse cx="54" cy="0" rx="2.5" ry="4.5" fill="#a16207"/></g>' +
'<g transform="rotate(14)"><rect x="-56" y="-8" width="112" height="16" rx="8" fill="url(#' + g + '-log)"/><ellipse cx="-54" cy="0" rx="5" ry="8" fill="#d6a46b"/><ellipse cx="-54" cy="0" rx="2.5" ry="4.5" fill="#a16207"/></g>' +
'<ellipse cx="-74" cy="10" rx="13" ry="8" fill="#3f3a52"/><ellipse cx="-48" cy="14" rx="12" ry="7" fill="#4c4663"/><ellipse cx="-18" cy="16" rx="12" ry="6.5" fill="#3f3a52"/>' +
'<ellipse cx="16" cy="16" rx="12" ry="6.5" fill="#4c4663"/><ellipse cx="46" cy="14" rx="12" ry="7" fill="#3f3a52"/><ellipse cx="72" cy="10" rx="13" ry="8" fill="#4c4663"/>' +
'</g>' +
'<g class="cfr-sparks"></g>' +
'</svg>';
var svg = el.firstChild;
var flamesG = svg.querySelector('.cfr-flames');
var sparksG = svg.querySelector('.cfr-sparks');
var aura = svg.querySelector('.cfr-aura');
var pool = svg.querySelector('.cfr-pool');
// Back to front: wide orange tongues, then yellow, then a pale core.
var tongues = [
{ x: -22, w: 16, h: 58, col: '#ea580c', f: 1.3, p: 0.0 },
{ x: 22, w: 16, h: 62, col: '#ea580c', f: 1.1, p: 2.1 },
{ x: 0, w: 30, h: 100, col: '#f97316', f: 0.9, p: 1.0 },
{ x: -10, w: 20, h: 70, col: '#fb923c', f: 1.5, p: 3.3 },
{ x: 12, w: 18, h: 66, col: '#fb923c', f: 1.7, p: 4.4 },
{ x: 0, w: 20, h: 70, col: '#fbbf24', f: 1.2, p: 5.2 },
{ x: 2, w: 11, h: 40, col: '#fef3c7', f: 1.9, p: 0.7 }
];
tongues.forEach(function (tg) {
tg.el = document.createElementNS(NS, 'path');
tg.el.setAttribute('fill', tg.col);
tg.el.setAttribute('transform', 'translate(' + tg.x + ' 0)');
flamesG.appendChild(tg.el);
});
var sparks = [];
for (var k = 0; k < count; k++) {
var c = document.createElementNS(NS, 'circle');
c.setAttribute('fill', k % 3 ? '#fdba74' : '#fef08a');
sparksG.appendChild(c);
sparks.push({ el: c, life: 0, max: 1 });
}
function spawn(s, anywhere) {
s.x = 200 + (Math.random() - 0.5) * 50;
s.y = 190 - Math.random() * 30;
s.vx = (Math.random() - 0.5) * 0.012;
s.vy = -(0.03 + Math.random() * 0.04);
s.r = 0.8 + Math.random() * 1.4;
s.max = 1800 + Math.random() * 2200;
s.life = anywhere ? Math.random() * s.max : 0;
s.ph = Math.random() * 6.28;
if (anywhere) { s.x += s.vx * s.life; s.y += s.vy * s.life; }
}
sparks.forEach(function (s) { spawn(s, true); });
var t = 0;
function draw(dt) {
t += dt * speed;
var s = t / 1000, total = 0;
tongues.forEach(function (tg) {
// A few sines at odd ratios: never quite repeating.
var a = Math.sin(s * 6.1 * tg.f + tg.p), b = Math.sin(s * 9.7 * tg.f + tg.p * 1.7), c = Math.sin(s * 3.3 * tg.f + tg.p * 0.6);
var h = tg.h * (1 + 0.12 * a + 0.07 * b);
var lean = tg.h * (0.1 * c + 0.06 * b);
total += h / tg.h;
tg.el.setAttribute('d', flame(tg.w * (1 + 0.05 * c), h, lean));
});
var heat = total / tongues.length;
aura.setAttribute('opacity', (0.75 + (heat - 1) * 2.4).toFixed(3));
pool.setAttribute('opacity', (0.8 + (heat - 1) * 2.8).toFixed(3));
for (var i = 0; i < sparks.length; i++) {
var p = sparks[i];
p.life += dt * speed;
if (p.life > p.max) spawn(p, false);
p.x += (p.vx + Math.sin(p.life / 260 + p.ph) * 0.02) * dt * speed;
p.y += p.vy * dt * speed;
var k = p.life / p.max;
p.el.setAttribute('cx', p.x.toFixed(1));
p.el.setAttribute('cy', p.y.toFixed(1));
p.el.setAttribute('r', (p.r * (1 - k * 0.6)).toFixed(2));
p.el.setAttribute('opacity', (k < 0.1 ? k / 0.1 : 1 - (k - 0.1) / 0.9).toFixed(2));
}
}
if (reduced()) {
// A still frame: flames mid-flicker, sparks frozen in the air.
draw(16);
return;
}
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
draw(dt);
}
});
draw(16);
// Pause while off screen to save battery.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
// Stars twinkle gently.
var starEls = svg.querySelectorAll('.cfr-star');
if (starEls.length) {
var tw = anime.animate(starEls, {
opacity: function () { return anime.utils.random(0.1, 0.9, 2); },
duration: function () { return anime.utils.random(1200, 3200); },
delay: function () { return anime.utils.random(0, 2000); },
ease: 'inOutSine',
loop: true,
alternate: true,
autoplay: false
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) tw.play(); else tw.pause();
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-campfire]').forEach(function (el) {
if (el._cfr) return; // idempotent
el._cfr = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-campfire></div>
<!-- full width, shorter, no stars: -->
<div data-campfire data-stars="false" style="height: 280px"></div>