A hero section for an evening event, a patio menu or a summer campaign often looks flat with a plain dark colour. A few dozen drifting fireflies fix that without a video file: they wander, pulse softly and never repeat the same pattern. This version is a single canvas and under a hundred lines of JavaScript, with no particle library.
Open late on Fridays
Summer evenings on the patioHow it works
- Draw one glow, reuse it. The script paints a single soft radial gradient onto a small hidden canvas once. Every firefly is that image stamped at a different size, which is far cheaper than drawing blurred shadows every frame.
- Give each firefly a personality. Each one gets its own position, heading, speed between 8 and 26 pixels per second, blink rate and size.
- Wander. Every frame nudges the heading by a small random amount, so paths curve lazily instead of moving in straight lines. A slight upward drift makes the scene feel like warm air rising. Fireflies that leave one edge come back on the opposite side.
- Pulse. Brightness follows a sine wave raised to the third power. That shape keeps each firefly dim most of the time with a short bright flash, closer to the real thing than a smooth fade.
- Glow where they overlap. Drawing with the
lighterblend mode adds light together, so two fireflies passing each other brighten naturally.
The count scales with the size of the section, capped at seventy, so a phone does not draw as many as a wide desktop.
The code
<div class="ff" data-lab="fireflies">
<canvas class="ff-sky" aria-hidden="true"></canvas>
<div class="ff-copy">
<p>Open late on Fridays</p>
<strong>Summer evenings on the patio</strong>
</div>
</div>.ff {
position: relative;
width: 100%;
height: 320px;
overflow: hidden;
border-radius: 16px;
background: radial-gradient(120% 90% at 50% 110%, #1f4a2f 0%, #0d2416 55%, #070f0a 100%);
font-family: system-ui, sans-serif;
}
.ff-sky { position: absolute; inset: 0; width: 100%; height: 100%; }
.ff-copy {
position: relative;
z-index: 1;
display: grid;
place-content: center;
height: 100%;
gap: 6px;
padding: 24px;
text-align: center;
color: #f2f3ef;
}
.ff-copy p { margin: 0; font-size: 13px; letter-spacing: .14em; text-transform: uppercase; color: #f4d96c; }
.ff-copy strong { font: 500 clamp(24px, 4vw, 34px)/1.15 Georgia, serif; }(function () {
var root = document.querySelector('[data-lab="fireflies"]');
if (!root) return;
var canvas = root.querySelector('.ff-sky');
var ctx = canvas.getContext('2d');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var flies = [], W = 0, H = 0, raf = 0, last = 0, visible = true;
// One soft glow, drawn once and stamped for every firefly. Much cheaper than shadowBlur.
var glow = document.createElement('canvas');
glow.width = glow.height = 64;
var g = glow.getContext('2d');
var grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, 'rgba(255, 248, 196, 1)');
grad.addColorStop(0.18, 'rgba(244, 217, 108, .9)');
grad.addColorStop(0.45, 'rgba(190, 220, 90, .25)');
grad.addColorStop(1, 'rgba(190, 220, 90, 0)');
g.fillStyle = grad;
g.fillRect(0, 0, 64, 64);
function makeFly() {
return {
x: Math.random() * W, y: Math.random() * H,
heading: Math.random() * Math.PI * 2,
speed: 8 + Math.random() * 18, // pixels per second
phase: Math.random() * Math.PI * 2,
rate: 0.6 + Math.random() * 1.4, // how fast it blinks
size: 10 + Math.random() * 14
};
}
function resize() {
var r = root.getBoundingClientRect();
var dpr = Math.min(2, window.devicePixelRatio || 1);
W = r.width; H = r.height;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var count = Math.min(70, Math.round(W * H / 7000)); // more room, more fireflies
while (flies.length < count) flies.push(makeFly());
flies.length = count;
}
function draw(t) {
ctx.clearRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter';
flies.forEach(function (f) {
var pulse = Math.pow((Math.sin(t / 1000 * f.rate + f.phase) + 1) / 2, 3);
ctx.globalAlpha = 0.12 + pulse * 0.88;
ctx.drawImage(glow, f.x - f.size / 2, f.y - f.size / 2, f.size, f.size);
});
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function step(t) {
var dt = Math.min(0.05, (t - last) / 1000 || 0.016);
last = t;
flies.forEach(function (f) {
f.heading += (Math.random() - 0.5) * 2.4 * dt; // a lazy, wandering path
f.x += Math.cos(f.heading) * f.speed * dt;
f.y += Math.sin(f.heading) * f.speed * dt - 2 * dt; // a slight upward drift
if (f.x < -20) f.x = W + 20; else if (f.x > W + 20) f.x = -20;
if (f.y < -20) f.y = H + 20; else if (f.y > H + 20) f.y = -20;
});
draw(t);
raf = visible ? requestAnimationFrame(step) : 0;
}
resize();
if (reduce) { draw(1200); return; } // one still frame for reduced motion
if ('ResizeObserver' in window) new ResizeObserver(resize).observe(root);
// Stop animating while the section is off screen.
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && !raf) { last = performance.now(); raf = requestAnimationFrame(step); }
}).observe(root);
})();When to use it, and when not to
Good fits: event pages, seasonal promotions, restaurant and café evening menus, a wedding or party site, a calm landing page for a spa. It sets a mood behind a headline while staying quiet enough to read over.
Keep it behind short text only, never behind paragraphs or forms, because moving light makes long reading harder. One animated background per page is plenty. The script already stops animating when the section scrolls out of view and shows one still frame to people who prefer reduced motion, which also saves battery.
Make it yours
- Colour: change the four
addColorStoplines. Cool blues make it a starfield; pinks suit a cherry blossom theme. - Density: the divisor in
W * H / 7000controls how crowded it gets; raise it for fewer fireflies. - Mood: slower
speedandratevalues feel sleepy; faster ones feel festive.
Frequently asked questions
Will it hurt page speed?
It loads no files and draws a few dozen small images per frame. It pauses whenever the section is off screen, so it costs nothing while people read the rest of the page.
Can I put it behind a full page?
Yes. Make the container fixed and full screen, and keep the content above it with a higher z-index. Keep the density low if text sits on top.
Why canvas instead of CSS animations?
Seventy independent, wandering elements would mean seventy DOM nodes with their own animations. One canvas redraw is lighter and makes the random paths easy.


