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>