Skyline Lights
The sun sets behind a city skyline and the sky fades from blue through a pink sunset to night. Windows light up one by one as it darkens, stars come out and a few lights flick on and off, until dawn comes and they go out again.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-skyline-lights>where the scene should go. It fills the element with an SVG scene.
Options
Add any of these attributes to the same element.
data-duration | Time for one day and night. Default 14000 ms. |
data-color | Colour of the window lights. Default #fde68a. |
data-loop="false" | Go from day to night once and stay at night. |
It is 8:5 by default; set your own height on the element to change that. The city is different every time and stretches to fill any width. It pauses when off screen. With reduced motion it shows the city at night.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Skyline Lights: the sun sets behind a city skyline, the sky fades from
// blue through sunset to night and the windows light up one by one as it
// darkens. Stars come out, a few lights switch on and off, then dawn
// comes and they go out again. Drawn as SVG that fills the element, and
// paused when off screen.
// Usage: <div data-skyline-lights></div>
// It is 8:5 by default; give it your own height to change that. The city
// stretches to fill any width.
// Optional: data-duration (one day and night, ms, default 14000),
// data-color (window light colour, default #fde68a), data-loop="false"
// (go from day to night once and stay at night).
var DEFAULTS = { duration: 14000, color: '#fde68a' };
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
var GROUND = 236;
var SKY = {
day: ['#60a5fa', '#93c5fd', '#dbeafe'],
dusk: ['#4c1d95', '#db2777', '#fb923c'],
night: ['#070b1f', '#1e1b4b', '#312e81']
};
var CITY = { day: ['#94a3b8', '#475569'], night: ['#27244d', '#0f0d24'] };
var css =
'[data-skyline-lights]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5;background:#1e1b4b}' +
'.skl-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; }
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function inOut(k) { k = clamp01(k); return k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; }
function rgb(h) { return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; }
function mix(a, b, k) {
var x = rgb(a), y = rgb(b);
return 'rgb(' + Math.round(x[0] + (y[0] - x[0]) * k) + ',' + Math.round(x[1] + (y[1] - x[1]) * k) + ',' + Math.round(x[2] + (y[2] - x[2]) * k) + ')';
}
// Darkness 0 (day) to 1 (night) at point p of the cycle.
function darkness(p) {
if (p < 0.1) return 0;
if (p < 0.45) return inOut((p - 0.1) / 0.35);
if (p < 0.78) return 1;
if (p < 0.95) return 1 - inOut((p - 0.78) / 0.17);
return 0;
}
function build(el) {
var d = el.dataset;
var n = ++uid;
var dur = Math.max(4000, +d.duration || DEFAULTS.duration);
var lightCol = d.color || DEFAULTS.color;
var loop = d.loop !== 'false';
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A city skyline lighting up as night falls');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
var g = 'skl' + n;
var stars = '';
for (var i = 0; i < 70; i++) {
stars += '<circle cx="' + (Math.random() * 1000 - 300).toFixed(1) + '" cy="' + (Math.random() * 260 - 100).toFixed(1) + '" r="' + (Math.random() * 0.8 + 0.35).toFixed(2) + '" opacity="' + (Math.random() * 0.6 + 0.4).toFixed(2) + '"/>';
}
// Two rows of buildings across more than the widest view.
var backHTML = '', frontHTML = '', windows = [], tallest = null;
var x = -280;
while (x < 680) {
var w = 26 + Math.random() * 30, h = 56 + Math.random() * 64;
backHTML += '<rect x="' + x.toFixed(1) + '" y="' + (GROUND - h).toFixed(1) + '" width="' + w.toFixed(1) + '" height="' + (h + 20).toFixed(1) + '"/>';
x += w + 2 + Math.random() * 6;
}
x = -270;
while (x < 670) {
var bw = 30 + Math.random() * 30, bh = 34 + Math.random() * 76;
var top = GROUND - bh;
frontHTML += '<rect x="' + x.toFixed(1) + '" y="' + top.toFixed(1) + '" width="' + bw.toFixed(1) + '" height="' + (bh + 20).toFixed(1) + '"/>';
if (Math.random() < 0.3) frontHTML += '<rect x="' + (x + bw * 0.2).toFixed(1) + '" y="' + (top - 8).toFixed(1) + '" width="' + (bw * 0.6).toFixed(1) + '" height="9"/>';
if (!tallest || bh > tallest.h) tallest = { x: x + bw / 2, top: top, h: bh };
var cols = Math.max(2, Math.floor((bw - 6) / 9)), rows = Math.floor((bh - 12) / 11);
var ox = x + (bw - (cols * 9 - 4)) / 2;
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
if (Math.random() < 0.18) continue;
windows.push({ x: ox + c * 9, y: top + 8 + r * 11, th: 0.3 + Math.random() * 0.66 });
}
}
x += bw + 1 + Math.random() * 5;
}
el.innerHTML =
'<svg class="skl-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
'<defs><linearGradient id="' + g + '-sky" gradientUnits="userSpaceOnUse" x1="0" y1="-60" x2="0" y2="' + GROUND + '">' +
'<stop offset="0"/><stop offset=".6"/><stop offset="1"/></linearGradient>' +
'<radialGradient id="' + g + '-halo"><stop offset="0" stop-color="#fde68a" stop-opacity=".5"/><stop offset="1" stop-color="#fde68a" stop-opacity="0"/></radialGradient></defs>' +
'<rect x="-700" y="-400" width="1800" height="1000" fill="url(#' + g + '-sky)"/>' +
'<g class="skl-stars" fill="#fff">' + stars + '</g>' +
'<g class="skl-moon"><circle cx="0" cy="0" r="22" fill="url(#' + g + '-halo)"/><circle r="10" fill="#fef9c3"/></g>' +
'<g class="skl-sun"><circle r="40" fill="url(#' + g + '-halo)"/><circle class="skl-disc" r="15"/></g>' +
'<g class="skl-back">' + backHTML + '</g>' +
'<g class="skl-front">' + frontHTML + '</g>' +
'<g class="skl-windows" fill-opacity=".9"></g>' +
'<circle class="skl-beacon" cx="' + tallest.x.toFixed(1) + '" cy="' + (tallest.top - 13).toFixed(1) + '" r="1.8" fill="#ef4444"/>' +
'<path d="M' + tallest.x.toFixed(1) + ' ' + tallest.top.toFixed(1) + 'V' + (tallest.top - 12).toFixed(1) + '" class="skl-mast" stroke-width="1.5"/>' +
'<rect class="skl-ground" x="-700" y="' + (GROUND - 1) + '" width="1800" height="400"/>' +
'</svg>';
var svg = el.firstChild;
var stops = svg.querySelectorAll('#' + g + '-sky stop');
var starsG = svg.querySelector('.skl-stars'), moon = svg.querySelector('.skl-moon');
var sun = svg.querySelector('.skl-sun'), disc = svg.querySelector('.skl-disc');
var backG = svg.querySelector('.skl-back'), frontG = svg.querySelector('.skl-front');
var ground = svg.querySelector('.skl-ground'), beacon = svg.querySelector('.skl-beacon'), mast = svg.querySelector('.skl-mast');
var winG = svg.querySelector('.skl-windows');
windows.forEach(function (wd) {
var rEl = document.createElementNS(NS, 'rect');
rEl.setAttribute('x', wd.x.toFixed(1));
rEl.setAttribute('y', wd.y.toFixed(1));
rEl.setAttribute('width', 5);
rEl.setAttribute('height', 6);
rEl.setAttribute('rx', 0.6);
winG.appendChild(rEl);
wd.el = rEl;
wd.lit = null;
wd.flip = false;
});
var flipTimer = 0;
function frame(p, t, dt) {
var dk = darkness(p);
var k = dk < 0.5 ? dk / 0.5 : (dk - 0.5) / 0.5;
var from = dk < 0.5 ? SKY.day : SKY.dusk, to = dk < 0.5 ? SKY.dusk : SKY.night;
for (var i = 0; i < 3; i++) stops[i].setAttribute('stop-color', mix(from[i], to[i], k));
// The sun sinks behind the city; the moon rises once it's dark.
var sy = 64 + dk * 230;
sun.setAttribute('transform', 'translate(304 ' + sy.toFixed(1) + ')');
disc.setAttribute('fill', mix('#fffbeb', '#f97316', clamp01(dk * 1.6)));
var mk = clamp01((dk - 0.65) / 0.35);
moon.setAttribute('transform', 'translate(92 ' + (78 - mk * 20).toFixed(1) + ')');
moon.setAttribute('opacity', mk.toFixed(2));
starsG.setAttribute('opacity', clamp01((dk - 0.55) / 0.45).toFixed(2));
var back = mix(CITY.day[0], CITY.night[0], dk), front = mix(CITY.day[1], CITY.night[1], dk);
backG.setAttribute('fill', back);
frontG.setAttribute('fill', front);
mast.setAttribute('stroke', front);
ground.setAttribute('fill', front);
beacon.setAttribute('opacity', dk > 0.4 && Math.sin(t * 0.004) > 0.2 ? 1 : 0);
// Late at night a few lights go on and off.
flipTimer -= dt;
if (dk > 0.95 && flipTimer < 0) {
flipTimer = 180;
var wf = windows[Math.floor(Math.random() * windows.length)];
if (wf) wf.flip = !wf.flip;
}
// Unlit windows inherit the group's colour; lit ones override it.
winG.setAttribute('fill', mix('#334155', '#1a1838', dk));
windows.forEach(function (wd) {
if (dk < 0.95) wd.flip = false;
var on = (dk > wd.th) !== wd.flip;
if (on === wd.lit) return;
wd.lit = on;
if (on) wd.el.setAttribute('fill', lightCol); else wd.el.removeAttribute('fill');
});
}
if (reduced()) { frame(0.6, 0, 0); return; }
var t = dur * 0.08, 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;
t += dt;
var p = loop ? (t % dur) / dur : Math.min(t / dur, 0.6);
frame(p, t, dt);
}
});
frame(0, 0, 0);
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-skyline-lights]').forEach(function (el) {
if (el._skl) return; // idempotent
el._skl = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-skyline-lights></div>
<!-- a wide banner that stays at night: -->
<div data-skyline-lights data-loop="false" style="aspect-ratio: auto; height: 260px"></div>