Design gallery
Backgrounds

Particle network

Drifting gold dots joined by thin lines, with lines reaching to the cursor.

Code 1 file

src/gallery/particles/Particles.astroAstro130 lines
---
// Drifting gold dots joined by thin lines, with lines reaching to the cursor.
// Fills its parent. No libraries. Plain background without JavaScript,
// one still frame with reduced motion, paused off screen.
---
<div class="particles" data-particles aria-hidden="true">
  <div class="glow"></div>
  <canvas></canvas>
</div>

<style>
  .particles { position: absolute; inset: 0; pointer-events: none; }
  .particles canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
  .glow {
    position: absolute; inset: 0;
    background:
      radial-gradient(55% 45% at 20% 10%, rgb(201 162 39 / 0.1), transparent 70%),
      radial-gradient(50% 40% at 85% 90%, rgb(201 162 39 / 0.07), transparent 70%);
  }
</style>

<script>
  type Dot = { x: number; y: number; vx: number; vy: number; r: number; a: number };
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const GOLD = '201, 162, 39';

  function mount(root: HTMLElement) {
    const canvas = root.querySelector('canvas');
    const ctx = canvas?.getContext('2d') ?? null;
    const host = root.parentElement;
    if (!canvas || !ctx || !host) return;

    let dots: Dot[] = [];
    let w = 0;
    let h = 0;
    let running = false;
    let visible = false;
    const pointer = { x: -9999, y: -9999 };

    const build = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      w = canvas.clientWidth;
      h = canvas.clientHeight;
      canvas.width = w * dpr;
      canvas.height = h * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      const cap = w < 640 ? 45 : 110;
      const count = Math.min(cap, Math.round((w * h) / 11000));
      dots = Array.from({ length: count }, () => ({
        x: Math.random() * w,
        y: Math.random() * h,
        vx: (Math.random() - 0.5) * 0.25,
        vy: (Math.random() - 0.5) * 0.25,
        r: 0.6 + Math.random() * 1.3,
        a: 0.25 + Math.random() * 0.45,
      }));
    };

    const draw = () => {
      ctx.clearRect(0, 0, w, h);
      const link = w < 640 ? 85 : 120;
      const reach = 160;
      for (let i = 0; i < dots.length; i++) {
        const d = dots[i];
        if (!reduce) {
          d.x += d.vx;
          d.y += d.vy;
          if (d.x < 0 || d.x > w) d.vx *= -1;
          if (d.y < 0 || d.y > h) d.vy *= -1;
        }
        ctx.beginPath();
        ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
        ctx.fillStyle = `rgba(${GOLD}, ${d.a})`;
        ctx.fill();
        for (let j = i + 1; j < dots.length; j++) {
          const o = dots[j];
          const dist = Math.hypot(d.x - o.x, d.y - o.y);
          if (dist < link) {
            ctx.strokeStyle = `rgba(${GOLD}, ${0.2 * (1 - dist / link)})`;
            ctx.lineWidth = 0.6;
            ctx.beginPath();
            ctx.moveTo(d.x, d.y);
            ctx.lineTo(o.x, o.y);
            ctx.stroke();
          }
        }
        const pd = Math.hypot(d.x - pointer.x, d.y - pointer.y);
        if (pd < reach) {
          ctx.strokeStyle = `rgba(${GOLD}, ${0.45 * (1 - pd / reach)})`;
          ctx.lineWidth = 0.8;
          ctx.beginPath();
          ctx.moveTo(d.x, d.y);
          ctx.lineTo(pointer.x, pointer.y);
          ctx.stroke();
        }
      }
    };

    const loop = () => {
      if (!running) return;
      draw();
      requestAnimationFrame(loop);
    };
    const update = () => {
      const should = visible && !document.hidden && !reduce;
      if (should && !running) {
        running = true;
        requestAnimationFrame(loop);
      } else if (!should) {
        running = false;
      }
    };

    new ResizeObserver(() => { build(); draw(); }).observe(canvas);
    new IntersectionObserver(([e]) => { visible = !!e?.isIntersecting; update(); }).observe(canvas);
    document.addEventListener('visibilitychange', update);
    if (!reduce) {
      host.addEventListener('pointermove', (e) => {
        const r = canvas.getBoundingClientRect();
        pointer.x = e.clientX - r.left;
        pointer.y = e.clientY - r.top;
      }, { passive: true });
      const clear = () => { pointer.x = pointer.y = -9999; };
      host.addEventListener('pointerleave', clear);
      host.addEventListener('pointerup', (e) => { if (e.pointerType === 'touch') clear(); });
    }
  }

  document.querySelectorAll<HTMLElement>('[data-particles]').forEach(mount);
</script>