Backgrounds
Starfield
Twinkling stars with a heartbeat pulse. The cursor parts them and links the nearest into constellations.
Code 1 file
src/gallery/starfield/Starfield.astroAstro226 lines
---
// Starlight background: twinkling stars that fill their parent.
// Heartbeat pulse (lub-dub) ripples out from the top centre; the cursor brightens
// and parts the stars and links the nearest into faint constellations; rare shooting
// stars; layered parallax on the pointer. Publishes the beat on window.__beat
// so a globe or orb on the same page pulses in time.
// Reduced motion: one still frame, redrawn on resize only. Pauses off screen.
---
<canvas class="starfield" data-starfield aria-hidden="true"></canvas>
<style>
.starfield { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; }
</style>
<script>
type Star = {
x: number; y: number; r: number; base: number;
phase: number; speed: number; layer: 0 | 1 | 2; tint: 0 | 1;
ox: number; oy: number; // current displacement from the cursor push
};
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const finePointer = matchMedia('(pointer: fine)');
const PERIOD = 4800; // ms between heartbeats
const BEAT_SPEED = 0.9; // px per ms the pulse travels outward
const PUSH_RADIUS = 190; // px around the cursor that react
const LAYER_PARALLAX = [4, 9, 16]; // px of pointer parallax per layer
const LINK_DIST = 110; // px: max length of a constellation line
// Pre-rendered glow sprites: warm white and sage.
const sprite = (rgb: string) => {
const s = document.createElement('canvas');
s.width = s.height = 64;
const g = s.getContext('2d')!;
const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, `rgba(${rgb},1)`);
grad.addColorStop(0.12, `rgba(${rgb},0.9)`);
grad.addColorStop(0.3, `rgba(${rgb},0.25)`);
grad.addColorStop(1, `rgba(${rgb},0)`);
g.fillStyle = grad;
g.fillRect(0, 0, 64, 64);
return s;
};
const sprites = [sprite('244,239,228'), sprite('168,196,166')];
const cursorGlow = sprite('159,208,156');
const rand = (a: number, b: number) => a + Math.random() * (b - a);
// Lub-dub: two gaussian swells, the second softer.
const gauss = (t: number, mu: number, sig: number) => Math.exp(-((t - mu) ** 2) / (2 * sig * sig));
const beat = (t: number) => gauss(t, 0, 90) + 0.6 * gauss(t, 290, 120);
function mount(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d');
const host = canvas.parentElement;
if (!ctx || !host) return;
const linked: number[] = []; // x, y, strength of stars near the cursor this frame
let w = 0, h = 0, dpr = 1;
let stars: Star[] = [];
let raf = 0, visible = false;
let mouseX = -9999, mouseY = -9999; // pointer, relative to the canvas
let sx = 0, sy = 0; // smoothed pointer for parallax
let pointerIn = false;
let shooting: { x: number; y: number; vx: number; vy: number; life: number } | null = null;
let nextShot = performance.now() + 3000 + Math.random() * 6000;
function build() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = canvas.clientWidth;
h = canvas.clientHeight;
if (!w || !h) return;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
const count = Math.min(900, Math.round((w * h) / 2600));
stars = Array.from({ length: count }, () => {
const roll = Math.random();
const layer: 0 | 1 | 2 = roll < 0.7 ? 0 : roll < 0.95 ? 1 : 2;
return {
x: Math.random() * w,
y: Math.random() * h,
r: layer === 0 ? rand(0.5, 0.95) : layer === 1 ? rand(0.95, 1.5) : rand(1.6, 2.4),
base: layer === 0 ? rand(0.25, 0.55) : layer === 1 ? rand(0.45, 0.75) : rand(0.7, 0.9),
phase: Math.random() * Math.PI * 2,
speed: rand(0.0006, 0.0022),
layer,
tint: Math.random() < 0.08 ? 1 : 0,
ox: 0, oy: 0,
};
});
}
function frame(now: number, still = false) {
ctx!.clearRect(0, 0, w, h);
sx += ((pointerIn ? mouseX : w / 2) - sx) * 0.06;
sy += ((pointerIn ? mouseY : h / 2) - sy) * 0.06;
const px = (sx - w / 2) / w, py = (sy - h / 2) / h;
const originX = w / 2, originY = h * 0.3;
const amp = 0.35;
const t = ((now % PERIOD) + PERIOD) % PERIOD;
(window as unknown as { __beat: number }).__beat = still ? 0 : beat(t) + beat(t - PERIOD);
linked.length = 0;
for (const s of stars) {
let x = s.x - px * LAYER_PARALLAX[s.layer];
let y = s.y - py * LAYER_PARALLAX[s.layer];
let near = 0;
if (!still && pointerIn) {
const dx = x - mouseX, dy = y - mouseY;
const d = Math.hypot(dx, dy);
if (d < PUSH_RADIUS && d > 0.01) {
near = 1 - d / PUSH_RADIUS;
const push = near * near * 22;
s.ox += ((dx / d) * push - s.ox) * 0.12;
s.oy += ((dy / d) * push - s.oy) * 0.12;
} else {
s.ox *= 0.92; s.oy *= 0.92;
}
} else {
s.ox *= 0.92; s.oy *= 0.92;
}
x += s.ox; y += s.oy;
let a = s.base;
let r = s.r;
if (!still) {
a *= 0.62 + 0.38 * Math.sin(now * s.speed + s.phase);
const delay = Math.hypot(x - originX, y - originY) / BEAT_SPEED;
const local = (((now - delay) % PERIOD) + PERIOD) % PERIOD;
const pulse = beat(local) + beat(local - PERIOD);
a *= 1 + pulse * amp * (s.layer === 0 ? 1 : 1.4);
r *= 1 + pulse * 0.25;
a += near * 0.8;
r *= 1 + near * 0.6;
}
a = Math.min(0.95, Math.max(0.12, a));
if (near > 0.15 && s.layer > 0 && linked.length < 40) linked.push(x, y, near);
const size = r * (s.layer === 2 ? 9 : 6);
ctx!.globalAlpha = a;
ctx!.drawImage(sprites[s.tint], x - size / 2, y - size / 2, size, size);
}
// Constellation lines between the brighter stars near the cursor.
if (linked.length > 3) {
ctx!.strokeStyle = '#d9e4d8';
ctx!.lineWidth = 0.6;
for (let i = 0; i < linked.length; i += 3) {
for (let k = i + 3; k < linked.length; k += 3) {
const d = Math.hypot(linked[i] - linked[k], linked[i + 1] - linked[k + 1]);
if (d > LINK_DIST) continue;
ctx!.globalAlpha = (1 - d / LINK_DIST) * Math.min(linked[i + 2], linked[k + 2]) * 0.55;
ctx!.beginPath();
ctx!.moveTo(linked[i], linked[i + 1]);
ctx!.lineTo(linked[k], linked[k + 1]);
ctx!.stroke();
}
}
}
if (!still && pointerIn && finePointer.matches) {
ctx!.globalAlpha = 0.07;
ctx!.drawImage(cursorGlow, mouseX - 220, mouseY - 220, 440, 440);
}
if (!still) drawShootingStar(now);
ctx!.globalAlpha = 1;
}
function drawShootingStar(now: number) {
if (!shooting && now > nextShot) {
const fromLeft = Math.random() < 0.5;
const speed = rand(1.1, 1.6);
const angle = rand(0.25, 0.5);
shooting = {
x: fromLeft ? rand(0, w * 0.5) : rand(w * 0.5, w),
y: rand(0, h * 0.4),
vx: (fromLeft ? 1 : -1) * Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 0,
};
}
if (!shooting) return;
const s = shooting;
s.life += 16;
s.x += s.vx * 16; s.y += s.vy * 16;
const fade = Math.sin(Math.min(1, s.life / 750) * Math.PI);
const segs = 14;
for (let i = 0; i < segs; i++) {
ctx!.globalAlpha = fade * (1 - i / segs) * 0.7;
const size = 4 - (i / segs) * 3;
ctx!.drawImage(sprites[0], s.x - s.vx * i * 7 - size * 2, s.y - s.vy * i * 7 - size * 2, size * 4, size * 4);
}
if (s.life > 750) {
shooting = null;
nextShot = now + rand(9000, 20000);
}
}
function loop(now: number) {
frame(now);
raf = requestAnimationFrame(loop);
}
function start() {
cancelAnimationFrame(raf);
if (reduce.matches) frame(0, true);
else if (visible && !document.hidden) raf = requestAnimationFrame(loop);
}
new ResizeObserver(() => { build(); start(); }).observe(canvas);
new IntersectionObserver(([e]) => { visible = !!e?.isIntersecting; start(); }).observe(canvas);
host.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'mouse') return;
const r = canvas.getBoundingClientRect();
mouseX = e.clientX - r.left; mouseY = e.clientY - r.top; pointerIn = true;
}, { passive: true });
host.addEventListener('pointerleave', () => { pointerIn = false; });
document.addEventListener('visibilitychange', start);
reduce.addEventListener('change', start);
}
document.querySelectorAll<HTMLCanvasElement>('[data-starfield]').forEach(mount);
</script>