Shooting Stars
A night sky full of softly twinkling stars, where every so often a shooting star streaks across, trailing a fading tail, and burns out.
Make a wish
How to use it
- Paste the snippet just before
</head>. - Add
data-shooting-starsto a section with a dark background.
Options
Add any of these attributes to the same element.
data-interval | Average time between shooting stars. Default 1600 ms. |
data-color | Colour of the streaks. Default white. |
data-stars | How many twinkling stars, as a multiplier. Default 1. |
data-angle | Direction of travel, in degrees below the horizontal. Use a negative angle to fly to the left. Default 28. |
It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion it shows a still starry sky.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Shooting Stars: a night sky of softly twinkling stars, where now and
// then a shooting star streaks across and burns out. Drawn on a
// transparent canvas behind your content, and paused when off screen.
// Usage: <section data-shooting-stars style="background:#0b1026">…</section>
// Optional: data-interval (average time between shooting stars in ms,
// default 1600), data-color (streak colour, default white), data-stars
// (how many stars, as a multiplier, default 1), data-angle (direction of
// travel in degrees below the horizontal, default 28; negative goes left).
var DEFAULTS = { interval: 1600, color: '#ffffff', stars: 1, angle: 28 };
var css =
'[data-shooting-stars]{position:relative;isolation:isolate;overflow:hidden}' +
'.shs-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 probe = document.createElement('canvas').getContext('2d');
function rgb(c) {
probe.fillStyle = '#000';
probe.fillStyle = c;
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 interval = +d.interval || DEFAULTS.interval;
var c = rgb(d.color || DEFAULTS.color);
var col = c[0] + ',' + c[1] + ',' + c[2];
var density = d.stars !== undefined ? +d.stars : DEFAULTS.stars;
var angle = (d.angle !== undefined ? +d.angle : DEFAULTS.angle) * Math.PI / 180;
var canvas = document.createElement('canvas');
canvas.className = 'shs-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, t = 0, next = 500;
var stars = [], meteors = [];
function star() {
var big = Math.random() < 0.08;
return {
x: Math.random(), y: Math.random(), // fractions, so resizing keeps the sky
r: big ? R(1.1, 1.7, 2) : R(0.35, 1, 2),
a: R(0.35, 0.95, 2),
tw: R(0.6, 2.4, 2), ph: R(0, 6.28, 2), // twinkle speed and phase
big: big
};
}
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 = Math.round(Math.min(500, (w * h) / 1500) * density);
while (stars.length < n) stars.push(star());
stars.length = Math.max(0, n);
if (still) draw();
}
// A new shooting star starts somewhere along the top or the side it
// comes from, and travels part of the way across before burning out.
function launch() {
var a = angle + R(-0.12, 0.12, 3);
var dir = Math.cos(a) >= 0 ? 1 : -1;
var x, y;
if (Math.random() < 0.7) { x = R(-0.1, 0.9, 3) * w; y = R(-0.05, 0.25, 3) * h; }
else { x = dir > 0 ? -10 : w + 10; y = R(0, 0.5, 3) * h; }
if (dir < 0 && x < w * 0.2) x = w - x;
var diag = Math.hypot(w, h);
meteors.push({
x: x, y: y, dx: Math.cos(a), dy: Math.abs(Math.sin(a)),
v: R(0.9, 1.4, 2) * Math.max(500, diag), // px per second
len: R(0.12, 0.22, 3) * Math.max(500, diag), // tail length
life: 0, dur: R(0.55, 1.0, 2), // seconds
width: R(1.2, 2, 2)
});
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// The stars.
ctx.fillStyle = '#fff';
for (var i = 0; i < stars.length; i++) {
var s = stars[i];
var tw = 0.55 + 0.45 * Math.sin(t * s.tw + s.ph);
ctx.globalAlpha = s.a * tw;
var x = s.x * w, y = s.y * h;
if (s.big) {
// The brightest get a soft halo.
var g = ctx.createRadialGradient(x, y, 0, x, y, s.r * 5);
g.addColorStop(0, 'rgba(255,255,255,.55)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(x - s.r * 5, y - s.r * 5, s.r * 10, s.r * 10);
ctx.fillStyle = '#fff';
}
ctx.beginPath();
ctx.arc(x, y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
// The shooting stars: a bright head with a tail that thins away.
ctx.lineCap = 'round';
for (var j = 0; j < meteors.length; j++) {
var m = meteors[j], p = m.life / m.dur;
var fade = Math.min(1, p * 6) * (1 - p * p);
var tail = m.len * Math.min(1, p * 3);
var tx = m.x - m.dx * tail, ty = m.y - m.dy * tail;
var lg = ctx.createLinearGradient(m.x, m.y, tx, ty);
lg.addColorStop(0, 'rgba(' + col + ',' + fade + ')');
lg.addColorStop(0.15, 'rgba(' + col + ',' + fade * 0.5 + ')');
lg.addColorStop(1, 'rgba(' + col + ',0)');
ctx.strokeStyle = lg;
ctx.lineWidth = m.width;
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(m.x, m.y);
ctx.stroke();
var hg = ctx.createRadialGradient(m.x, m.y, 0, m.x, m.y, 7);
hg.addColorStop(0, 'rgba(255,255,255,' + fade + ')');
hg.addColorStop(0.3, 'rgba(' + col + ',' + fade * 0.5 + ')');
hg.addColorStop(1, 'rgba(' + col + ',0)');
ctx.fillStyle = hg;
ctx.fillRect(m.x - 7, m.y - 7, 14, 14);
}
}
function advance(dt) {
var s = dt / 1000;
t += s;
next -= dt;
if (next <= 0) {
launch();
next = interval * R(0.4, 1.6, 2);
}
for (var i = meteors.length - 1; i >= 0; i--) {
var m = meteors[i];
m.life += s;
m.x += m.dx * m.v * s;
m.y += m.dy * m.v * s;
if (m.life >= m.dur) meteors.splice(i, 1);
}
}
var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (still) { draw(); 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;
advance(dt);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-shooting-stars]').forEach(function (el) {
if (el._shs) return;
el._shs = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-shooting-stars style="background: radial-gradient(ellipse at 50% 120%, #312e81, #0b1026 60%); color: #fff">
<h1>Make a wish</h1>
</section>