Stage Spotlights
Coloured spotlights rise from the foot of a section and sweep back and forth through the haze like the lights at a concert, their beams crossing and blending where they meet.
Tonight only
How to use it
- Paste the snippet just before
</head>. - Add
data-stage-spotlightsto a section with a dark background.
Options
Add any of these attributes to the same element.
data-colors | Comma-separated colours, one per light and repeated. Default violet, pink, cyan and amber. |
data-count | How many lights. Default one per 190 px of width, from 3 to 7. |
data-width | How wide each beam spreads, in degrees. Default 9. |
data-speed | Speed multiplier. Default 1. |
It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the lights hold still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Stage Spotlights: coloured spotlights rise from the foot of a section
// and sweep back and forth through the haze like the lights at a concert,
// their beams crossing and blending where they meet. Drawn on a
// transparent canvas behind your content, and paused when off screen.
// Usage: <section data-stage-spotlights style="background:#07050d">…</section>
// Optional: data-colors (comma-separated, one per light and repeated,
// default violet, pink, cyan and amber), data-count (how many lights,
// default one per 190 px of width, 3 to 7), data-speed (default 1),
// data-width (beam spread in degrees, default 9).
var DEFAULTS = { colors: '#a855f7,#ec4899,#22d3ee,#fbbf24', speed: 1, width: 9 };
var css =
'[data-stage-spotlights]{position:relative;isolation:isolate;overflow:hidden}' +
'.ssl-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);
var TAU = Math.PI * 2;
// [spread, opacity] of each nested cone, outermost first.
var CONES = [[1.7, 0.05], [1.3, 0.07], [0.95, 0.09], [0.62, 0.12], [0.3, 0.2]];
var probe = document.createElement('canvas').getContext('2d');
function rgb(c) {
probe.fillStyle = '#000';
probe.fillStyle = c.trim();
var v = probe.fillStyle;
if (v[0] === '#') return [parseInt(v.slice(1, 3), 16), parseInt(v.slice(3, 5), 16), parseInt(v.slice(5, 7), 16)];
return v.match(/[\d.]+/g).slice(0, 3).map(Number);
}
function build(el) {
var d = el.dataset;
var cols = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return rgb(c).join(','); });
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var spread = ((+d.width || DEFAULTS.width) * Math.PI) / 360; // half-angle
var canvas = document.createElement('canvas');
canvas.className = 'ssl-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var R = anime.utils.random;
var w = 0, h = 0, dpr = 1, lights = [], t = 3;
var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
var n = +d.count || Math.max(3, Math.min(7, Math.round(w / 190)));
if (lights.length !== n) {
lights = [];
for (var i = 0; i < n; i++) {
// Lights lean inwards from the sides, so the beams cross mid-stage.
var f = n > 1 ? i / (n - 1) : 0.5;
lights.push({
f: f,
lean: (0.5 - f) * 0.7,
amp: R(28, 46) / 100,
w1: R(35, 60) / 100,
w2: R(15, 30) / 100,
ph: R(0, 628) / 100,
col: cols[i % cols.length]
});
}
}
draw();
}
function beam(x, y, a, len, half, col, alpha) {
var ex = x + Math.cos(a) * len, ey = y + Math.sin(a) * len;
var px = -Math.sin(a), py = Math.cos(a), far = Math.tan(half) * len, near = 3;
var g = ctx.createLinearGradient(x, y, ex, ey);
g.addColorStop(0, 'rgba(' + col + ',' + alpha + ')');
g.addColorStop(0.35, 'rgba(' + col + ',' + (alpha * 0.45).toFixed(3) + ')');
g.addColorStop(1, 'rgba(' + col + ',0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(x + px * near, y + py * near);
ctx.lineTo(ex + px * far, ey + py * far);
ctx.lineTo(ex - px * far, ey - py * far);
ctx.lineTo(x - px * near, y - py * near);
ctx.closePath();
ctx.fill();
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.globalCompositeOperation = 'lighter';
var len = Math.hypot(w, h) * 1.1;
var margin = Math.min(60, w * 0.08);
for (var i = 0; i < lights.length; i++) {
var L = lights[i];
var x = margin + (w - margin * 2) * L.f, y = h + 6;
var swing = L.amp * (0.7 * Math.sin(t * L.w1 * speed + L.ph) + 0.3 * Math.sin(t * L.w2 * speed * 2.3 + L.ph * 1.7));
var a = -Math.PI / 2 + L.lean + swing;
// Nested cones give the beam a soft edge and a brighter core.
for (var k = 0; k < CONES.length; k++) beam(x, y, a, len * (k > 3 ? 0.8 : 1), spread * CONES[k][0], L.col, CONES[k][1]);
// The lamp itself glows at the foot of the stage.
var g = ctx.createRadialGradient(x, h, 0, x, h, 46);
g.addColorStop(0, 'rgba(255,255,255,0.75)');
g.addColorStop(0.15, 'rgba(' + L.col + ',0.6)');
g.addColorStop(1, 'rgba(' + L.col + ',0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, h, 46, 0, TAU);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (still) return;
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += (last ? Math.min(50, now - last) : 16) / 1000;
last = now;
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-stage-spotlights]').forEach(function (el) {
if (el._ssl) return;
el._ssl = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-stage-spotlights style="background: #07050d; color: #fff">
<h1>Tonight only</h1>
</section>