25 CSS Background Animations10 / 25

Light JSMIT licensed

Particle Constellation Network

The connected-dots network, rewritten as forty lines of dependency-free Canvas 2D instead of a 60KB library. Particles drift, link to neighbours within a radius, and pull toward the cursor; density scales to the viewport area, the loop is DPR-correct, and it stops itself entirely when the section scrolls out of view or the user asks for reduced motion.

Published

Live Demo
Try it

The code

<section class="bga-10" id="bga-10-root" aria-label="Particle constellation network background demo">
  <canvas class="bga-10__c" id="bga-10-canvas" aria-hidden="true"></canvas>
  <div class="bga-10__wash" aria-hidden="true"></div>

  <div class="bga-10__stage">
    <div class="bga-10__panel">
      <p class="bga-10__eyebrow">Canvas 2D · 10</p>
      <h1 class="bga-10__title">A network that knows when to stop.</h1>
      <p class="bga-10__sub">Half-matrix pair testing, one batched path per frame, DPR-correct lines, and an IntersectionObserver that kills the loop the moment it leaves the viewport.</p>
      <ul class="bga-10__stats">
        <li><b id="bga-10-n">—</b><span>nodes</span></li>
        <li><b>O(n²)/2</b><span>pair tests</span></li>
        <li><b>1</b><span>stroke call</span></li>
        <li><b>~1.4 KB</b><span>no deps</span></li>
      </ul>
      <p class="bga-10__hint">Move the pointer — nodes lean toward it.</p>
    </div>
  </div>
</section>
.bga-10,
.bga-10 *,
.bga-10 *::before,
.bga-10 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.bga-10 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  position: relative;
  overflow: hidden;
  isolation: isolate;
  --bga-10-bg: oklch(0.15 0.02 256);
  --bga-10-acc: oklch(0.82 0.15 178);
  background: var(--bga-10-bg);
  color: oklch(0.97 0.008 220);
  font-family: ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
  -webkit-font-smoothing: antialiased;
}

.bga-10__c {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

.bga-10__wash {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(80% 62% at 50% 46%,color-mix(in oklab,var(--bga-10-acc) 9%,transparent),transparent 68%), radial-gradient(120% 100% at 50% 50%,transparent 45%,oklch(0.1 0.02 256/.8) 100%);
}

.bga-10__stage {
  position: relative;
  z-index: 2;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  padding: clamp(22px,5vw,68px);
  pointer-events: none;
}

.bga-10__panel {
  width: min(100%,700px);
  display: grid;
  gap: 16px;
  justify-items: center;
  text-align: center;
  padding: clamp(26px,3.8vw,48px);
  border-radius: 22px;
  background: oklch(0.13 0.018 256/.62);
  border: 1px solid oklch(1 0 0/.12);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  box-shadow: 0 44px 100px -52px oklch(0 0 0/.95);
}

.bga-10__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .24em;
  text-transform: uppercase;
  color: var(--bga-10-acc);
}

.bga-10__title {
  font-size: clamp(26px,5vw,50px);
  font-weight: 640;
  line-height: 1.05;
  letter-spacing: -.045em;
  text-wrap: balance;
}

.bga-10__sub {
  max-width: 52ch;
  font-size: clamp(13.5px,1.3vw,16px);
  line-height: 1.6;
  color: oklch(0.92 0.01 220/.76);
  text-wrap: pretty;
}

.bga-10__stats {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
  margin-top: 4px;
}

.bga-10__stats li {
  display: grid;
  gap: 2px;
  min-width: 98px;
  padding: 10px 14px;
  border-radius: 12px;
  background: oklch(1 0 0/.05);
  border: 1px solid oklch(1 0 0/.1);
}

.bga-10__stats b {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 17px;
  font-weight: 600;
  color: var(--bga-10-acc);
  letter-spacing: -.02em;
}

.bga-10__stats span {
  font-size: 10px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: oklch(0.85 0.01 220/.6);
}

.bga-10__hint {
  font-size: 12px;
  color: oklch(0.85 0.01 220/.45);
}

@media (prefers-reduced-motion: reduce) {
  .bga-10__hint {
    display: none;
  }
}
(() => {
  const root = document.getElementById('bga-10-root');
  const cv = document.getElementById('bga-10-canvas');
  if (!root || !cv) return;
  const ctx = cv.getContext('2d');
  const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const LINK = 132;
  let w = 0, h = 0, dpr = 1, parts = [], raf = 0, live = false;
  const mouse = { x: -9999, y: -9999 };

  const size = () => {
    const r = root.getBoundingClientRect();
    w = r.width; h = r.height;
    dpr = Math.min(devicePixelRatio || 1, 2);
    cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    const n = Math.min(120, Math.max(28, Math.round(w * h / 13000)));
    parts = Array.from({ length: n }, () => ({
      x: Math.random() * w, y: Math.random() * h,
      vx: (Math.random() - .5) * .34, vy: (Math.random() - .5) * .34,
      r: Math.random() * 1.6 + .9
    }));
    const out = document.getElementById('bga-10-n');
    if (out) out.textContent = String(n);
  };

  const frame = () => {
    ctx.clearRect(0, 0, w, h);
    for (const p of parts) {
      p.x += p.vx; p.y += p.vy;
      if (p.x < 0 || p.x > w) p.vx *= -1;
      if (p.y < 0 || p.y > h) p.vy *= -1;
      const dx = mouse.x - p.x, dy = mouse.y - p.y, d2 = dx * dx + dy * dy;
      if (d2 < 26000 && d2 > 1) { p.x += dx / d2 * 22; p.y += dy / d2 * 22; }
    }
    ctx.beginPath();
    for (let i = 0; i < parts.length; i++) {
      for (let j = i + 1; j < parts.length; j++) {
        const dx = parts[i].x - parts[j].x, dy = parts[i].y - parts[j].y;
        const d2 = dx * dx + dy * dy;
        if (d2 < LINK * LINK) {
          ctx.moveTo(parts[i].x, parts[i].y);
          ctx.lineTo(parts[j].x, parts[j].y);
        }
      }
    }
    ctx.strokeStyle = 'rgba(120,220,220,0.16)';
    ctx.lineWidth = 1;
    ctx.stroke();
    ctx.fillStyle = 'rgba(190,245,240,0.85)';
    for (const p of parts) {
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
      ctx.fill();
    }
    if (live) raf = requestAnimationFrame(frame);
  };

  const start = () => { if (!live && !calm) { live = true; raf = requestAnimationFrame(frame); } };
  const stop = () => { live = false; cancelAnimationFrame(raf); };

  size();
  if (calm) { frame(); } else {
    new IntersectionObserver((es) => es.forEach(e => e.isIntersecting ? start() : stop()), { threshold: 0 }).observe(root);
  }

  let t;
  addEventListener('resize', () => { clearTimeout(t); t = setTimeout(() => { size(); if (calm) frame(); }, 160); });
  root.addEventListener('pointermove', (e) => {
    const r = root.getBoundingClientRect();
    mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
  }, { passive: true });
  root.addEventListener('pointerleave', () => { mouse.x = mouse.y = -9999; });
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Background Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Particle Constellation Network
Source: https://codefronts.com/motion/css-background-animations/particle-constellation-network/

The connected-dots network, rewritten as forty lines of dependency-free Canvas 2D instead of a 60KB library. Particles drift, link to neighbours within a radius, and pull toward the cursor; density scales to the viewport area, the loop is DPR-correct, and it stops itself entirely when the section scrolls out of view or the user asks for reduced motion.
## HTML
```html
<section class="bga-10" id="bga-10-root" aria-label="Particle constellation network background demo">
  <canvas class="bga-10__c" id="bga-10-canvas" aria-hidden="true"></canvas>
  <div class="bga-10__wash" aria-hidden="true"></div>

  <div class="bga-10__stage">
    <div class="bga-10__panel">
      <p class="bga-10__eyebrow">Canvas 2D · 10</p>
      <h1 class="bga-10__title">A network that knows when to stop.</h1>
      <p class="bga-10__sub">Half-matrix pair testing, one batched path per frame, DPR-correct lines, and an IntersectionObserver that kills the loop the moment it leaves the viewport.</p>
      <ul class="bga-10__stats">
        <li><b id="bga-10-n">—</b><span>nodes</span></li>
        <li><b>O(n²)/2</b><span>pair tests</span></li>
        <li><b>1</b><span>stroke call</span></li>
        <li><b>~1.4 KB</b><span>no deps</span></li>
      </ul>
      <p class="bga-10__hint">Move the pointer — nodes lean toward it.</p>
    </div>
  </div>
</section>
```
## CSS
```css
.bga-10,
.bga-10 *,
.bga-10 *::before,
.bga-10 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.bga-10 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  position: relative;
  overflow: hidden;
  isolation: isolate;
  --bga-10-bg: oklch(0.15 0.02 256);
  --bga-10-acc: oklch(0.82 0.15 178);
  background: var(--bga-10-bg);
  color: oklch(0.97 0.008 220);
  font-family: ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
  -webkit-font-smoothing: antialiased;
}

.bga-10__c {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

.bga-10__wash {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(80% 62% at 50% 46%,color-mix(in oklab,var(--bga-10-acc) 9%,transparent),transparent 68%), radial-gradient(120% 100% at 50% 50%,transparent 45%,oklch(0.1 0.02 256/.8) 100%);
}

.bga-10__stage {
  position: relative;
  z-index: 2;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  padding: clamp(22px,5vw,68px);
  pointer-events: none;
}

.bga-10__panel {
  width: min(100%,700px);
  display: grid;
  gap: 16px;
  justify-items: center;
  text-align: center;
  padding: clamp(26px,3.8vw,48px);
  border-radius: 22px;
  background: oklch(0.13 0.018 256/.62);
  border: 1px solid oklch(1 0 0/.12);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  box-shadow: 0 44px 100px -52px oklch(0 0 0/.95);
}

.bga-10__eyebrow {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  letter-spacing: .24em;
  text-transform: uppercase;
  color: var(--bga-10-acc);
}

.bga-10__title {
  font-size: clamp(26px,5vw,50px);
  font-weight: 640;
  line-height: 1.05;
  letter-spacing: -.045em;
  text-wrap: balance;
}

.bga-10__sub {
  max-width: 52ch;
  font-size: clamp(13.5px,1.3vw,16px);
  line-height: 1.6;
  color: oklch(0.92 0.01 220/.76);
  text-wrap: pretty;
}

.bga-10__stats {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
  margin-top: 4px;
}

.bga-10__stats li {
  display: grid;
  gap: 2px;
  min-width: 98px;
  padding: 10px 14px;
  border-radius: 12px;
  background: oklch(1 0 0/.05);
  border: 1px solid oklch(1 0 0/.1);
}

.bga-10__stats b {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 17px;
  font-weight: 600;
  color: var(--bga-10-acc);
  letter-spacing: -.02em;
}

.bga-10__stats span {
  font-size: 10px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: oklch(0.85 0.01 220/.6);
}

.bga-10__hint {
  font-size: 12px;
  color: oklch(0.85 0.01 220/.45);
}

@media (prefers-reduced-motion: reduce) {
  .bga-10__hint {
    display: none;
  }
}
```

## JavaScript
```js
(() => {
  const root = document.getElementById('bga-10-root');
  const cv = document.getElementById('bga-10-canvas');
  if (!root || !cv) return;
  const ctx = cv.getContext('2d');
  const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
  const LINK = 132;
  let w = 0, h = 0, dpr = 1, parts = [], raf = 0, live = false;
  const mouse = { x: -9999, y: -9999 };

  const size = () => {
    const r = root.getBoundingClientRect();
    w = r.width; h = r.height;
    dpr = Math.min(devicePixelRatio || 1, 2);
    cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    const n = Math.min(120, Math.max(28, Math.round(w * h / 13000)));
    parts = Array.from({ length: n }, () => ({
      x: Math.random() * w, y: Math.random() * h,
      vx: (Math.random() - .5) * .34, vy: (Math.random() - .5) * .34,
      r: Math.random() * 1.6 + .9
    }));
    const out = document.getElementById('bga-10-n');
    if (out) out.textContent = String(n);
  };

  const frame = () => {
    ctx.clearRect(0, 0, w, h);
    for (const p of parts) {
      p.x += p.vx; p.y += p.vy;
      if (p.x < 0 || p.x > w) p.vx *= -1;
      if (p.y < 0 || p.y > h) p.vy *= -1;
      const dx = mouse.x - p.x, dy = mouse.y - p.y, d2 = dx * dx + dy * dy;
      if (d2 < 26000 && d2 > 1) { p.x += dx / d2 * 22; p.y += dy / d2 * 22; }
    }
    ctx.beginPath();
    for (let i = 0; i < parts.length; i++) {
      for (let j = i + 1; j < parts.length; j++) {
        const dx = parts[i].x - parts[j].x, dy = parts[i].y - parts[j].y;
        const d2 = dx * dx + dy * dy;
        if (d2 < LINK * LINK) {
          ctx.moveTo(parts[i].x, parts[i].y);
          ctx.lineTo(parts[j].x, parts[j].y);
        }
      }
    }
    ctx.strokeStyle = 'rgba(120,220,220,0.16)';
    ctx.lineWidth = 1;
    ctx.stroke();
    ctx.fillStyle = 'rgba(190,245,240,0.85)';
    for (const p of parts) {
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
      ctx.fill();
    }
    if (live) raf = requestAnimationFrame(frame);
  };

  const start = () => { if (!live && !calm) { live = true; raf = requestAnimationFrame(frame); } };
  const stop = () => { live = false; cancelAnimationFrame(raf); };

  size();
  if (calm) { frame(); } else {
    new IntersectionObserver((es) => es.forEach(e => e.isIntersecting ? start() : stop()), { threshold: 0 }).observe(root);
  }

  let t;
  addEventListener('resize', () => { clearTimeout(t); t = setTimeout(() => { size(); if (calm) frame(); }, 160); });
  root.addEventListener('pointermove', (e) => {
    const r = root.getBoundingClientRect();
    mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
  }, { passive: true });
  root.addEventListener('pointerleave', () => { mouse.x = mouse.y = -9999; });
})();
```

How this works

The naive version of this effect compares every particle to every other one and then wonders why phones catch fire. The fix is the loop bounds — each particle only ever tests the particles after it in the array:

for (let i = 0; i < p.length; i++)
  for (let j = i + 1; j < p.length; j++) { ... }

That halves the comparisons, and squared-distance testing avoids a square root per pair:

const dx = a.x - b.x, dy = a.y - b.y, d2 = dx*dx + dy*dy;
if (d2 < LINK * LINK) {
  ctx.globalAlpha = 1 - d2 / (LINK * LINK);
  ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y);
}

All lines are batched into one path and stroked once per frame rather than issuing a stroke call per link — the single biggest win in this kind of effect.

Three production details most tutorials skip:

  • DPR scaling. The backing store is sized w * devicePixelRatio and the context scaled once, so lines are crisp on retina instead of soft.
  • IntersectionObserver. The rAF loop is cancelled when the section leaves the viewport. A background animating off-screen is pure battery drain.
  • Reduced motion. One static frame is drawn and no loop starts at all — the composition still looks intentional.

The cursor influence is a soft radial pull, not a hard repel, and it decays with distance so the field reorganises smoothly rather than exploding.

Make it yours

  • Tune LINK (link radius) and the density divisor together: more particles with a shorter radius reads as fine mesh, fewer with a longer radius reads as constellation.
  • Colour each link by its length with oklch() and the normalised distance for a heat-mapped network.
  • Replace the cursor pull with a repel by flipping the sign — repel suits hero sections, pull suits interactive diagrams.
  • Feed real data: give each particle a label and draw text above a size threshold to turn the background into a live node graph.

Gotchas — read before shipping

  • Never resize the canvas inside the animation frame — clearing and reallocating the backing store every frame destroys performance. Resize on a debounced resize listener only.
  • Setting canvas.width resets the entire 2D context state, including the DPR scale. Re-apply ctx.scale() after every resize.
  • Uncapped particle counts on 4K displays will melt a laptop. Derive the count from area with a hard maximum, as here.
  • Always cancel the rAF handle on cleanup; a stale loop keeps running after a route change in SPAs and leaks a whole canvas.
  • Canvas content is invisible to assistive tech. Mark it aria-hidden and never put meaningful content in it.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by oklch(), color-mix(), backdrop-filter, text-wrap as this demo is written. The core technique itself goes back further — Chrome any modern.

Canvas 2D, requestAnimationFrame, IntersectionObserver (Safari 12.1+) and matchMedia are supported in every current engine. No library, no build step, ~1.4KB minified.

Techniques used in this demo

Search CodeFronts

Loading…