Design gallery
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>