Spiral Galaxy
Thousands of stars wheel round a bright core, the inner ones faster, and two spiral arms stand out where their orbits crowd together. Each star follows its own oval, each oval is turned a little further than the one inside it, and the arms are simply where the ovals bunch up, which is how astronomers think real spiral arms form.
How to use it
- Paste the snippet just before
</head>. - Add
data-spiral-galaxyto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-stars | How many stars. Default scaled to the box’s area, 1500 to 9000. |
data-twist | How tightly the arms wind. Default 3.2. |
data-tilt | How far the disc leans back, from 0 (face-on) to 0.8. Default 0.45. |
data-colors | Core, arm and rim colours. Default #fff7d6,#c4b5fd,#7dd3fc. |
data-speed | Speed multiplier. Default 1. |
The stars are drawn with additive blending, so where they crowd together they glow brighter. It’s paused when off screen, and with reduced motion it shows a still galaxy.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Spiral Galaxy: thousands of stars wheel round a bright core, and two
// spiral arms appear where their orbits crowd together. Each star follows
// its own oval orbit, and each oval is turned a little further than the
// one inside it, which is how real spiral arms are thought to form. The
// galaxy is tilted towards you and turns slowly.
// Usage: <div data-spiral-galaxy style="height:360px;background:#07040f"></div>
// Optional: data-stars (default scaled to the box's area, 1500 to 9000),
// data-twist (how tightly the arms wind, default 3.2), data-tilt (how far
// it leans back, 0 flat-on to 0.8, default 0.45), data-colors (core, arm
// and outer colours, default "#fff7d6,#c4b5fd,#7dd3fc"), data-speed
// (speed multiplier, default 1).
var DEFAULTS = { twist: 3.2, tilt: 0.45, colors: '#fff7d6,#c4b5fd,#7dd3fc', speed: 1 };
var css =
'[data-spiral-galaxy]{position:relative;isolation:isolate;overflow:hidden}' +
'.sgx-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(hex) {
var c = hex.replace('#', '');
if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
var n = parseInt(c, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
function mix(a, b, t) {
return 'rgb(' + Math.round(a[0] + (b[0] - a[0]) * t) + ',' + Math.round(a[1] + (b[1] - a[1]) * t) + ',' +
Math.round(a[2] + (b[2] - a[2]) * t) + ')';
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'sgx-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var twist = d.twist !== undefined ? +d.twist : DEFAULTS.twist;
var tilt = d.tilt !== undefined ? Math.max(0, Math.min(0.8, +d.tilt)) : DEFAULTS.tilt;
var cols = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return rgb(c.trim()); });
while (cols.length < 3) cols.push(cols[cols.length - 1]);
var speed = +d.speed || DEFAULTS.speed;
var w = 0, h = 0, dpr = 1, R = 1, t = 0, spin = 0;
var BUCKETS = 6, palette = [], stars = [];
// Colour buckets from core to rim, so stars can be drawn in a few batches.
for (var b = 0; b < BUCKETS; b++) {
var f = b / (BUCKETS - 1);
palette.push(f < 0.5 ? mix(cols[0], cols[1], f * 2) : mix(cols[1], cols[2], (f - 0.5) * 2));
}
function makeStars() {
var n = +d.stars || Math.round(Math.max(1500, Math.min(9000, w * h / 26)));
stars = [];
for (var i = 0; i < n; i++) {
// More stars near the middle, thinning out towards the rim.
var r = 0.06 + 0.94 * Math.pow(Math.random(), 0.85);
if (Math.random() < 0.18) r = Math.pow(Math.random(), 1.5) * 0.16;
stars.push({
r: r,
e: 0.5 + Math.random() * 0.06, // how squashed the oval is
a: Math.random() * Math.PI * 2, // where on its oval it is
j: (Math.random() - 0.5) * 0.035, // a little scatter
k: (Math.random() - 0.5) * 0.035,
s: Math.random() < 0.06 ? 2 : 1, // the odd bright star
b: Math.min(BUCKETS - 1, Math.floor(Math.min(1, r * 1.3 + Math.random() * 0.15) * BUCKETS))
});
}
stars.sort(function (p, q) { return p.b - q.b; });
}
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return false;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
// Big enough to fill the box, allowing for the tilt squashing it.
R = Math.min(w * 0.5, h * 0.5 / (1 - tilt * 0.75)) * 1.12;
makeStars();
return true;
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var cx = w / 2, cy = h / 2, sy = 1 - tilt * 0.75;
var lean = -0.35;
var cl = Math.cos(lean), sl = Math.sin(lean);
// A faint haze across the disc.
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(lean);
ctx.scale(1, sy);
var haze = ctx.createRadialGradient(0, 0, 0, 0, 0, R);
haze.addColorStop(0, 'rgba(196,181,253,.22)');
haze.addColorStop(0.4, 'rgba(129,140,248,.08)');
haze.addColorStop(1, 'rgba(99,102,241,0)');
ctx.fillStyle = haze;
ctx.beginPath();
ctx.arc(0, 0, R, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.globalCompositeOperation = 'lighter';
var bucket = -1;
for (var i = 0; i < stars.length; i++) {
var s = stars[i];
if (s.b !== bucket) { bucket = s.b; ctx.fillStyle = palette[bucket]; ctx.globalAlpha = 0.85 - bucket * 0.07; }
// Inner stars go round faster.
var ang = s.a + t / (0.18 + s.r) * 0.35;
var rot = s.r * twist + spin;
var ex = Math.cos(ang) * s.r, ey = Math.sin(ang) * s.r * s.e;
var x = ex * Math.cos(rot) - ey * Math.sin(rot) + s.j;
var y = ex * Math.sin(rot) + ey * Math.cos(rot) + s.k;
// Tilt the disc and lean it a little.
x *= R; y *= R * sy;
var px = cx + x * cl - y * sl, py = cy + x * sl + y * cl;
ctx.fillRect(px, py, s.s * 0.9, s.s * 0.9);
}
ctx.globalAlpha = 1;
// The bright core.
var cr = R * 0.28;
var core = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr);
core.addColorStop(0, 'rgba(255,255,255,.95)');
core.addColorStop(0.15, 'rgba(255,240,200,.6)');
core.addColorStop(0.5, 'rgba(250,204,150,.15)');
core.addColorStop(1, 'rgba(250,204,150,0)');
ctx.fillStyle = core;
ctx.fillRect(cx - cr, cy - cr, cr * 2, cr * 2);
ctx.globalCompositeOperation = 'source-over';
}
resize();
t = 4;
draw();
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
if (reduced()) 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;
t += dt / 1000 * speed;
spin += dt / 1000 * speed * 0.06;
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-spiral-galaxy]').forEach(function (el) {
if (el._sgx) return;
el._sgx = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-spiral-galaxy style="height: 360px; background: #07040f"></div>
<!-- face-on, tightly wound: -->
<div data-spiral-galaxy data-tilt="0" data-twist="5" style="height: 360px; background: #07040f"></div>