Lighthouse
A striped lighthouse on the rocks turns its lamp through the night. The beam sweeps out across the sea, swings round and flashes bright as it faces you, lighting a patch of water wherever it falls, while the waves roll in and the stars twinkle.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-lighthouse>where the scene should go. It fills the element with an SVG scene.
Options
Add any of these attributes to the same element.
data-color | Colour of the light. Default #fef3c7. |
data-stripes | Colour of the tower’s stripes and roof. Default #ef4444. |
data-speed | Speed multiplier. At 1 the lamp turns once every 7 seconds. Default 1. |
data-stars="false" | No stars. |
It is 8:5 with a night-sky background by default; set your own height or background on the element to change that. The sea stretches to fill any width. It pauses when off screen. With reduced motion it shows the beam still, pointing out to sea.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Lighthouse: a striped lighthouse on the rocks turns its beam through
// the night. The beam sweeps out across the sea, swings round, flashes
// bright as it faces you and lights a patch of water where it falls,
// while stars twinkle and the waves roll in. Drawn as SVG that fills the
// element, and paused when off screen.
// Usage: <div data-lighthouse></div>
// It is 8:5 by default with a night-sky background; give it your own
// height or background to change that. The sea stretches to fill any width.
// Optional: data-color (colour of the light, default #fef3c7),
// data-stripes (colour of the tower's stripes, default #ef4444),
// data-speed (default 1: one turn every 7 s), data-stars="false" (no stars).
var DEFAULTS = { color: '#fef3c7', stripes: '#ef4444', speed: 1 };
var NS = 'http://www.w3.org/2000/svg';
var TURN = 7000;
var LX = 290, LY = 79; // the lamp
var uid = 0;
var css =
'[data-lighthouse]{display:block;position:relative;overflow:hidden;width:100%;aspect-ratio:8/5;' +
'background:linear-gradient(#070b1f,#16204a 58%,#2c3a6b)}' +
'.lhs-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 build(el) {
var d = el.dataset;
var n = ++uid;
var light = d.color || DEFAULTS.color;
var stripes = d.stripes || DEFAULTS.stripes;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'A lighthouse sweeping its beam across the sea at night');
if (!el.hasAttribute('role')) el.setAttribute('role', 'img');
var g = 'lhs' + n;
var stars = '';
if (d.stars !== 'false') {
for (var i = 0; i < 90; i++) {
var x = -220 + Math.random() * 840, y = -30 + Math.random() * 185 * Math.random() + Math.random() * 10;
stars += '<circle class="lhs-star" cx="' + x.toFixed(1) + '" cy="' + y.toFixed(1) + '" r="' + (0.5 + Math.random() * 1.1).toFixed(2) + '" fill="#fff" opacity="' + (0.35 + Math.random() * 0.6).toFixed(2) + '"/>';
}
}
// The tower: a tapering trunk with stripes clipped to it.
var tower = 'M272 196L279 94L301 94L308 196Z';
var bands = '';
for (var b = 0; b < 4; b++) bands += '<rect x="260" y="' + (104 + b * 25) + '" width="60" height="12" fill="' + stripes + '"/>';
var waves = '';
for (var w = 0; w < 14; w++) {
var wy = 178 + w * w * 0.45 + w * 3;
var wx = -380 + Math.random() * 110;
waves += '<g class="lhs-wave" data-y="' + wy.toFixed(1) + '" data-v="' + (4 + w * 1.4).toFixed(1) + '">';
for (var k = 0; k < 11; k++) waves += '<path d="M' + (wx + k * 110).toFixed(1) + ' 0q6 -2.5 12 0t12 0" fill="none" stroke="#a5b4fc" stroke-opacity="' + (0.12 + w * 0.012).toFixed(3) + '" stroke-width="1.2" stroke-linecap="round"/>';
waves += '</g>';
}
el.innerHTML =
'<svg class="lhs-svg" viewBox="0 0 400 250" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">' +
'<defs>' +
'<linearGradient id="' + g + '-beam"><stop offset="0" stop-color="' + light + '" stop-opacity=".75"/><stop offset=".4" stop-color="' + light + '" stop-opacity=".22"/><stop offset=".8" stop-color="' + light + '" stop-opacity=".04"/><stop offset="1" stop-color="' + light + '" stop-opacity="0"/></linearGradient>' +
'<radialGradient id="' + g + '-glow"><stop offset="0" stop-color="' + light + '" stop-opacity="1"/><stop offset=".3" stop-color="' + light + '" stop-opacity=".45"/><stop offset="1" stop-color="' + light + '" stop-opacity="0"/></radialGradient>' +
'<radialGradient id="' + g + '-pool"><stop offset="0" stop-color="' + light + '" stop-opacity=".5"/><stop offset="1" stop-color="' + light + '" stop-opacity="0"/></radialGradient>' +
'<clipPath id="' + g + '-tower"><path d="' + tower + '"/></clipPath>' +
'</defs>' +
'<g class="lhs-stars">' + stars + '</g>' +
'<path d="M84 34A17 17 0 1 0 104 58A13 13 0 1 1 84 34Z" fill="#fef9c3" opacity=".9"/>' +
'<rect class="lhs-flash" x="-900" y="-300" width="2200" height="1000" fill="' + light + '" opacity="0"/>' +
// Sea and a far headland.
'<path d="M-900 172L-60 172Q-10 160 40 166Q80 158 120 172Z" fill="#141c3d"/>' +
'<rect x="-900" y="171" width="2200" height="500" fill="#121a3a"/>' +
'<ellipse class="lhs-pool" cx="0" cy="206" rx="70" ry="9" fill="url(#' + g + '-pool)"/>' +
waves +
'<g class="lhs-glints" fill="' + light + '"></g>' +
// The beam sits behind the lantern room.
'<g transform="translate(' + LX + ' ' + LY + ')"><path class="lhs-beam" d="M0 -3L560 -50L560 50L0 3Z" fill="url(#' + g + '-beam)"/></g>' +
// Rocks and the tower.
'<path d="M230 204Q236 186 254 184Q262 172 282 176L300 180Q318 174 330 186Q350 188 356 204Z" fill="#0b1029"/>' +
'<path d="M254 186Q262 176 280 179" stroke="#2a3566" stroke-width="2" fill="none" stroke-linecap="round"/>' +
'<path class="lhs-foam" d="M226 204Q240 198 254 203Q270 199 288 204Q308 198 326 203Q344 198 360 204" fill="none" stroke="#c7d2fe" stroke-width="2" stroke-linecap="round" opacity=".5"/>' +
'<path d="' + tower + '" fill="#f1f5f9"/>' +
'<g clip-path="url(#' + g + '-tower)">' + bands + '<rect x="294" y="90" width="20" height="110" fill="#000" opacity=".22"/></g>' +
'<path d="M285 196L285 182Q290 177 295 182L295 196Z" fill="#334155"/>' +
'<rect x="287" y="134" width="6" height="8" rx="3" fill="' + light + '" opacity=".8"/>' +
'<rect x="274" y="90" width="32" height="5" rx="1.5" fill="#1e293b"/>' +
'<path d="M276 90L276 84M284 90L284 84M296 90L296 84M304 90L304 84M275 84L305 84" stroke="#1e293b" stroke-width="1.4"/>' +
'<rect x="281" y="70" width="18" height="16" fill="' + light + '"/>' +
'<path d="M287 70L287 86M293 70L293 86" stroke="#1e293b" stroke-width="1.4" opacity=".7"/>' +
'<path d="M278 71Q290 54 302 71Z" fill="' + stripes + '"/><rect x="278" y="69" width="24" height="3" rx="1" fill="#1e293b"/>' +
'<path d="M290 58L290 52" stroke="#1e293b" stroke-width="1.6" stroke-linecap="round"/>' +
'<circle class="lhs-glow" cx="' + LX + '" cy="' + LY + '" r="20" fill="url(#' + g + '-glow)"/>' +
'</svg>';
var svg = el.firstChild;
var beam = svg.querySelector('.lhs-beam');
var glow = svg.querySelector('.lhs-glow');
var flash = svg.querySelector('.lhs-flash');
var pool = svg.querySelector('.lhs-pool');
var foam = svg.querySelector('.lhs-foam');
var starEls = svg.querySelectorAll('.lhs-star');
var twinkle = [];
for (var s = 0; s < starEls.length; s += 3) twinkle.push({ el: starEls[s], base: +starEls[s].getAttribute('opacity'), ph: Math.random() * 6, f: 0.8 + Math.random() * 2 });
var waveEls = [].slice.call(svg.querySelectorAll('.lhs-wave')).map(function (e) {
return { el: e, y: +e.getAttribute('data-y'), v: +e.getAttribute('data-v') };
});
// Glints: a shimmering column of light on the water below the lamp.
var gl = svg.querySelector('.lhs-glints');
var glints = [];
for (var q = 0; q < 7; q++) {
var r = document.createElementNS(NS, 'rect');
r.setAttribute('height', '1.6');
r.setAttribute('rx', '.8');
gl.appendChild(r);
glints.push({ el: r, y: 210 + q * 6, ph: Math.random() * 6 });
}
function frame(t) {
var sec = t / 1000;
var a = t / TURN * Math.PI * 2;
var sx = Math.sin(a), facing = Math.max(0, Math.cos(a));
var f4 = Math.pow(facing, 4);
// The beam is foreshortened as it turns towards or away from you, and
// fattens a little as it faces you.
beam.setAttribute('transform', 'scale(' + sx.toFixed(4) + ' ' + (1 + 0.5 * f4).toFixed(3) + ')');
beam.setAttribute('opacity', (0.55 + 0.45 * Math.abs(sx) * (1 - 0.4 * Math.max(0, -Math.cos(a)))).toFixed(3));
glow.setAttribute('r', (16 + 70 * Math.pow(facing, 6)).toFixed(1));
glow.setAttribute('opacity', (0.55 + 0.45 * f4).toFixed(3));
flash.setAttribute('opacity', (0.07 * Math.pow(facing, 10)).toFixed(4));
// Where the beam falls on the water.
pool.setAttribute('cx', (LX + sx * 190).toFixed(1));
pool.setAttribute('rx', (30 + 70 * Math.abs(sx)).toFixed(1));
pool.setAttribute('opacity', (Math.abs(sx) * 0.9).toFixed(3));
var bright = 0.35 + 0.65 * f4;
glints.forEach(function (gt, i) {
var wv = 6 + i * 2.6 + 3 * Math.sin(sec * 2.2 + gt.ph);
gt.el.setAttribute('x', (LX - wv / 2 + 2 * Math.sin(sec * 1.3 + gt.ph)).toFixed(1));
gt.el.setAttribute('y', gt.y);
gt.el.setAttribute('width', wv.toFixed(1));
gt.el.setAttribute('opacity', (bright * (0.25 + 0.3 * Math.sin(sec * 3 + gt.ph * 2)) * (1 - i * 0.08)).toFixed(3));
});
waveEls.forEach(function (wv) {
var x = (sec * wv.v) % 110;
wv.el.setAttribute('transform', 'translate(' + x.toFixed(1) + ' ' + (wv.y + Math.sin(sec * 0.9 + wv.y) * 0.8).toFixed(1) + ')');
});
foam.setAttribute('opacity', (0.3 + 0.3 * Math.sin(sec * 1.4)).toFixed(2));
foam.setAttribute('transform', 'translate(0 ' + (Math.sin(sec * 1.4) * 1.2).toFixed(2) + ')');
twinkle.forEach(function (st) {
st.el.setAttribute('opacity', (st.base * (0.55 + 0.45 * Math.sin(sec * st.f + st.ph))).toFixed(2));
});
}
if (reduced()) { frame(TURN * 0.2); return; }
var t = TURN * 0.6, 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) * speed;
last = now;
frame(t);
}
});
frame(t);
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-lighthouse]').forEach(function (el) {
if (el._lhs) return; // idempotent
el._lhs = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-lighthouse></div>
<!-- a slower, blue-striped lighthouse: -->
<div data-lighthouse data-speed="0.6" data-stripes="#2563eb"></div>