Back to blog
12 min readParallel Studio

Particle Background for a Website: a particles.js Alternative With No Library

particle backgroundparticles.js alternativecanvascssstarfieldwebflowframerfree tool

A particle background is a field of small dots behind your content: stars, embers, dust or snow. The usual way to get one is to install particles.js or a similar library. You often don't need to. This post shows two lighter routes, and how to drop either into HTML, Webflow, Framer, WordPress, Shopify or React:

  1. No JavaScript at all. A static starfield in pure CSS, built from box-shadow. This is what the free particle background generator writes for you.
  2. A small canvas loop. About sixty lines of plain JavaScript for dots that drift, join up with link lines and react to the mouse, the look people usually install particles.js for.

At the end: when it's time to stop hand-rolling and move to WebGL.

Do you need the particles to move?

Decide this first, because it changes everything else.

  • Still, or a gentle twinkle: use CSS. No script, no canvas, nothing to pause, and it works anywhere you can paste CSS.
  • Drifting dots, link lines, mouse interaction: use a canvas and a requestAnimationFrame loop.
  • Thousands of particles, 3D, or morphing into shapes and logos: use WebGL. More on that at the end.

Option 1: a particle background in pure CSS

The generator's trick is one tiny element with a long box-shadow list. Each entry is an x offset, a y offset, an optional blur and a color, and each one paints a copy of that element somewhere else. One pseudo-element becomes hundreds of dots, with no image and no script.

Here is real output from the generator's Ember preset, cut down to 12 particles so it fits on the page:

/* 12 particles in 3 layers, placed inside a 1440 x 900px area (seed 21). */

.stars {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background: linear-gradient(180deg, #0e0705, #2b1209);
}

.stars::before,
.stars::after,
.stars-near::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  z-index: -1;
  border-radius: 50%;
  pointer-events: none;
}

.stars::before {
  width: 1.5px;
  height: 1.5px;
  box-shadow:
    644px 519px 3.4px #ff9a4d66,
    638px 578px 3.4px #ff9a4d4d,
    1319px 134px 3.4px #ff9a4d63,
    704px 150px 3.4px #ff9a4d52,
    963px 521px 3.4px #ff9a4d5c,
    264px 477px 3.4px #ff9a4d52,
    1335px 594px 3.4px #ff9a4d69;
}

.stars::after {
  width: 3px;
  height: 3px;
  box-shadow:
    922px 345px 6.8px #ffd9a094,
    1131px 523px 6.8px #ff9a4d61,
    175px 136px 6.8px #ff9a4d96,
    1398px 437px 6.8px #ffd9a070;
}

.stars-near::before {
  width: 4.5px;
  height: 4.5px;
  box-shadow:
    124px 492px 10.1px #ff9a4dd4;
}
<section class="stars">
  <div class="stars-near"></div>
  <!-- your content here -->
</section>

How it works:

  • Depth comes from layers. The generator splits the particles into one to three layers. With three, the far layer gets 55% of the particles, the middle 30% and the near layer 15%. The far layer uses your smallest size and the near layer your largest, and the far layers are dimmer, so small faint dots read as distant.
  • Each layer is a pseudo-element. ::before and ::after give two layers. The third needs one more element, which is why the HTML has a stars-near div.
  • The alpha is in the color. #ff9a4d66 is the ember orange with a two-digit alpha on the end. Every dot gets a slightly different opacity, so the field doesn't look stamped.
  • Glow is the blur. The blur radius scales with the particle size and the glow setting. Set glow to 0 and the blur drops out of each entry.
  • isolation: isolate and z-index: -1 keep the dots behind your content but inside the section, and overflow: hidden clips any that fall outside it.

Where the dots go

The generator has three distributions:

  • Uniform: spread evenly across the area.
  • Clustered: a few soft clumps plus a thin scatter, like a nebula.
  • Horizon: dense along a line at 72% of the height, thinning upwards, with nothing below it. The Ember preset uses this one.

Positions come from a seeded random generator (mulberry32). The same seed and settings always give the same layout, in the CSS, the SVG and the PNG. Shuffle picks a new seed, and Copy link saves the exact settings in the URL.

Twinkle and drift, with reduced motion built in

Motion is off by default. Turn on twinkle and each layer fades between full and 35% opacity on its own cycle (5, 7 and 9 seconds, offset so the layers don't pulse together). Drift slowly moves each layer a few pixels over 60 seconds or more. Both are plain CSS animations, and the generator always adds the reduced-motion override with them:

@keyframes stars-twinkle {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.35; }
}

@media (prefers-reduced-motion: reduce) {
  .stars::before,
  .stars::after,
  .stars-near::before {
    animation: none;
  }
}

The limit: 600 particles

Every particle is text in your stylesheet, so the generator stops at 600 (and starts at 10). That's plenty for a starfield behind a hero. If you need more than that, or every dot to move on its own, you've outgrown CSS.

The tool also exports the same field as an SVG (one <circle> per particle) or a PNG at 1920×1080 or 1000×1500. Use the PNG where you can't add CSS, like slides or social images.

Option 2: a canvas particle background (no library)

What most people install particles.js for is moving dots joined by thin lines, which push away from the cursor. You can write that yourself in plain JavaScript. The generator doesn't output this. It's a hand-written starter, and every number in it is a setting to tune, not a benchmark.

1. The canvas and devicePixelRatio

A canvas has two sizes: its CSS size on the page and its drawing buffer in real pixels. On a high-density screen, a buffer the same size as the CSS box looks blurry. Size the buffer by devicePixelRatio, then scale the context so you can keep drawing in CSS pixels:

<section class="particles">
  <canvas aria-hidden="true"></canvas>
  <!-- your content here -->
</section>

<style>
  .particles { position: relative; isolation: isolate; background: #0b0b0c; }
  .particles canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; }
</style>
const canvas = document.querySelector(".particles canvas");
const ctx = canvas.getContext("2d");
let w = 0, h = 0;

function resize() {
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  w = canvas.clientWidth;
  h = canvas.clientHeight;
  canvas.width = Math.round(w * dpr);
  canvas.height = Math.round(h * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
new ResizeObserver(resize).observe(canvas);
resize();

Capping the ratio at 2 is a choice: a 3x phone screen has to fill a lot more pixels each frame for a difference you'll barely see on soft dots. aria-hidden keeps the decoration out of screen readers, and pointer-events: none keeps clicks going to your content.

2. Particles and the requestAnimationFrame loop

Give each particle a position and a small velocity, move it every frame, and wrap it around the edges:

const COUNT = 80;
const LINK = 120; // px: join particles closer than this
const dots = Array.from({ length: COUNT }, () => ({
  x: Math.random() * w,
  y: Math.random() * h,
  vx: (Math.random() - 0.5) * 0.4,
  vy: (Math.random() - 0.5) * 0.4,
}));

function step() {
  ctx.clearRect(0, 0, w, h);
  for (const d of dots) {
    d.x = (d.x + d.vx + w) % w;
    d.y = (d.y + d.vy + h) % h;
  }
  drawLinks();
  ctx.fillStyle = "#f2d9b3";
  for (const d of dots) {
    ctx.beginPath();
    ctx.arc(d.x, d.y, 1.5, 0, Math.PI * 2);
    ctx.fill();
  }
}

requestAnimationFrame runs your function before the next repaint, in step with the display, and the browser throttles it in background tabs. Don't drive animation with setInterval.

Compare each pair of particles, and draw a line when they're close, fading out as they move apart:

function drawLinks() {
  ctx.lineWidth = 1;
  for (let i = 0; i < dots.length; i++) {
    for (let j = i + 1; j < dots.length; j++) {
      const dx = dots[i].x - dots[j].x;
      const dy = dots[i].y - dots[j].y;
      const dist = Math.hypot(dx, dy);
      if (dist < LINK) {
        ctx.strokeStyle = `rgba(232, 121, 74, ${(1 - dist / LINK) * 0.5})`;
        ctx.beginPath();
        ctx.moveTo(dots[i].x, dots[i].y);
        ctx.lineTo(dots[j].x, dots[j].y);
        ctx.stroke();
      }
    }
  }
}

This checks every pair, so the work grows with the square of the count: twice the particles is four times the checks. That's why the count here stays small. If you need many more linked particles, split the area into a grid of cells and only compare neighbours, or move to the GPU.

4. Mouse interaction

Track the pointer and push nearby particles away from it. Listen on window, because the canvas has pointer-events: none:

const mouse = { x: -9999, y: -9999 };
window.addEventListener("pointermove", (e) => {
  const r = canvas.getBoundingClientRect();
  mouse.x = e.clientX - r.left;
  mouse.y = e.clientY - r.top;
});
document.documentElement.addEventListener("pointerleave", () => { mouse.x = mouse.y = -9999; });

function repel(d) {
  const dx = d.x - mouse.x, dy = d.y - mouse.y;
  const dist = Math.hypot(dx, dy);
  if (dist > 0 && dist < 100) {
    d.x += (dx / dist) * 2;
    d.y += (dy / dist) * 2;
  }
}

Call repel(d) inside the movement loop in step(). Touch screens don't have a hover, so treat this as a nice extra on desktop, not something the design depends on.

5. prefers-reduced-motion

Some visitors ask their system for less motion. Respect it: draw one still frame instead of looping.

const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");

The loop in the next step checks reduce.matches before scheduling another frame.

6. Pause when it's offscreen or the tab is hidden

A background below the fold, or in a hidden tab, shouldn't burn battery. Use an IntersectionObserver for the section and visibilitychange for the tab, and only run the loop when both say it's visible:

let onScreen = true, raf = 0;

function loop() {
  step();
  raf = requestAnimationFrame(loop);
}
function update() {
  cancelAnimationFrame(raf);
  raf = 0;
  if (reduce.matches) { step(); return; } // one still frame
  if (onScreen && !document.hidden) raf = requestAnimationFrame(loop);
}

new IntersectionObserver(([entry]) => { onScreen = entry.isIntersecting; update(); }).observe(canvas);
document.addEventListener("visibilitychange", update);
reduce.addEventListener("change", update);
update();

Put it all in one <script> at the end of the page (or with defer), after the canvas, and you have a particles.js-style background with no dependency.

Copy-paste steps by platform

For the CSS version, copy the CSS and HTML from the generator. For the canvas version, use the snippets above.

Plain HTML

Paste the <style> into your <head> and the <section> where you want the background. For canvas, put the <script> just before </body>.

Webflow

CSS: add an Embed with the generator's HTML + CSS inside a section, or paste the CSS into the page's custom code and give the section the class stars. Canvas: add a Code Embed with the <canvas> inside a section with the class particles, and put the script in Page settings → Custom code → Before </body>. Custom code needs a paid site plan and runs on the published site. More on the Webflow effects page.

Framer

CSS: paste the CSS into Site Settings → Custom Code in a <style> tag and add the class to the frame, or drop the HTML output into an Embed. Canvas: put the section, canvas and script together in one Embed, sized to fill its parent. See Framer effects.

WordPress

Give a Group block the class stars (Advanced → Additional CSS class) and paste the CSS into Appearance → Customize → Additional CSS. For canvas, use a Custom HTML block with the section, canvas and script. See WordPress effects.

Shopify

Paste the CSS into base.css (or a section's {% style %} block) and add the class to your section. For canvas, add a Custom Liquid section with the markup and script. See Shopify effects.

React

The CSS version is just a class:

<section className="stars">
  <div className="stars-near" />
  {children}
</section>

For canvas, run the setup in an effect and clean up when the component unmounts:

function ParticleCanvas() {
  const ref = useRef<HTMLCanvasElement>(null);
  useEffect(() => {
    const canvas = ref.current!;
    // ...the resize, step, drawLinks, repel and update code from above...
    return () => {
      // cancelAnimationFrame(raf), disconnect the observers, remove the listeners
    };
  }, []);
  return <canvas ref={ref} aria-hidden="true" />;
}

See React effects for more.

When to move to WebGL

Canvas 2D draws every dot and every line on the CPU, one call at a time. It's fine for a modest field like the one above. It is not the tool for many thousands of particles, real 3D depth, or particles that gather into shapes, text or a logo.

That's what Particle Forms is for. It renders on the GPU with WebGL2, with an automatic Canvas 2D fallback, and handles up to 15,000 particles at 60fps. Particles morph between 3D shapes, your text and your logo, and react to the cursor with repel, attract or swirl, plus click bursts and touch on phones. It drops in with one script tag, a React component or a Framer component, with no Three.js. It's $12 one-time, and the Webflow walkthrough is in How to Add a WebGL Particle Animation to Webflow.

If what you want is a globe, an interactive 3D sphere of particles you can drag, with markers and arcs for data, see Particle Sphere.

Pricing

The particle background generator is free, with no sign-up, and nothing is uploaded. Particle Forms is $12 one-time. The whole catalog, Particle Forms and Particle Sphere included, is in the All-Access Pass for $49. Browse everything at /effects.

Start with CSS. Add a canvas loop if the dots need to move, and switch to WebGL when the canvas can't keep up.