A soft field of drifting dust behind a hero headline adds depth and atmosphere without shouting for attention — the effect you see at the top of our own site works exactly this way. You do not need a library, sprite sheets or WebGL to build one. With the Canvas 2D API, around 150 lines of plain JavaScript and a little physics, you can create a particle system that looks smooth on any refresh rate, stays sharp on high-density screens, resizes gracefully, costs very little CPU and switches itself off for people who prefer reduced motion. This tutorial builds it step by step and explains every decision along the way.
What we are building, and why sprite-free#
Our particle system draws each particle directly as a small filled circle (or a tiny square when it is too small for the difference to be visible). There are no image sprites to download, decode or tint, which keeps the effect at zero extra network requests and lets us change color, size and opacity freely per particle.
The feature list:
- A loop driven by
requestAnimationFramewith delta time, so motion is identical at 60 Hz and 144 Hz. - Simple, stable physics: a spring toward a slowly rising anchor, random jitter and frame-rate independent drag.
- A depth value per particle that controls size, opacity and speed for a parallax feel.
- Respawning with fade-in and fade-out, so particles never pop in or out and no objects are created after start-up.
- Sharp rendering with devicePixelRatio, resize handling with
ResizeObserver, and pausing when off-screen. - Full respect for
prefers-reduced-motion.
If you are still deciding whether Canvas is the right renderer at all, our comparison of SVG, Canvas and WebGL explains the trade-offs. For a few hundred small moving dots, Canvas 2D is the sweet spot.
Setting up the canvas#
Markup and CSS#
The canvas is purely decorative, so it is hidden from assistive technology and ignores pointer events so it never blocks clicks on the content above it:
<section class="hero">
<canvas class="hero-dust" aria-hidden="true"></canvas>
<div class="hero-content">
<h1>Your headline</h1>
</div>
</section>
.hero {
position: relative;
isolation: isolate; /* keeps the negative z-index inside the hero */
}
.hero-dust {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: -1;
pointer-events: none;
}
Because the canvas is absolutely positioned and sized by CSS, it can never cause layout shift, and the hero text remains the Largest Contentful Paint element.
Configuration and state#
Keep every tunable number in one object. It makes the effect easy to art-direct and documents the units of each value:
const canvas = document.querySelector('.hero-dust');
const ctx = canvas.getContext('2d');
const CONFIG = {
density: 1 / 9000, // particles per CSS pixel² of canvas area
minCount: 24,
maxCount: 220,
maxDpr: 2, // cap the backing-store resolution
minRadius: 0.6, // CSS px at depth 0 (far)
maxRadius: 2.4, // CSS px at depth 1 (near)
rise: 14, // px/s upward anchor drift for the nearest layer
spring: 0.9, // spring stiffness toward the anchor, 1/s²
drag: 1.6, // velocity damping rate, 1/s
jitter: 22, // random kick strength, px/s per √s
color: '255, 236, 210'
};
const particles = [];
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let width = 0, height = 0, dpr = 1;
let running = false, visible = true, rafId = 0, last = 0;
Sharp pixels with devicePixelRatio#
A canvas has a CSS size and a separate bitmap size. On a screen with a devicePixelRatio of 2 or 3, a bitmap that matches the CSS size gets stretched and looks blurry. We size the bitmap in device pixels, then apply a transform so all drawing code can keep working in CSS pixels. We cap the ratio at 2: on a 3× phone the difference for soft dust is invisible, but the pixel count — and the fill cost — would be 2.25 times higher.
function resize() {
const rect = canvas.getBoundingClientRect();
width = Math.max(1, rect.width);
height = Math.max(1, rect.height);
dpr = Math.min(window.devicePixelRatio || 1, CONFIG.maxDpr);
canvas.width = Math.round(width * dpr); // resets all context state…
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // …so re-apply the transform after
syncCount();
if (!running) draw(); // keep the static frame correct when paused
}
function syncCount() {
const target = Math.round(Math.min(CONFIG.maxCount,
Math.max(CONFIG.minCount, width * height * CONFIG.density)));
while (particles.length < target) particles.push(spawn({}, true));
particles.length = target; // truncates when the canvas shrinks
}
Scaling the particle count with the area keeps the visual density consistent from a phone to an ultrawide monitor, while the cap protects large screens from doing unnecessary work.
The particle model#
Each particle is a small plain object with a position, velocity, an anchor that a spring pulls it toward, a depth value z between 0 (far) and 1 (near), and an age and lifetime in seconds:
function spawn(p, initial = false) {
p.z = Math.random() ** 1.5; // biased toward small, distant dust
p.x = p.ax = Math.random() * width;
p.y = p.ay = Math.random() * height;
p.vx = p.vy = 0;
p.life = 6 + Math.random() * 8; // seconds
p.age = initial ? Math.random() * p.life : 0;
return p;
}
Two details matter. First, raising Math.random() to the power of 1.5 skews the distribution toward 0, so there are more small, faint, distant particles than large, bright, near ones — which is how real depth looks. Second, the very first batch starts at random ages, so particles do not all fade in together and then die together in a synchronized pulse.
spawn() takes an existing object and reinitializes it rather than creating a new one. After start-up, the animation allocates nothing, so the garbage collector has nothing to clean up and never causes a mid-animation hitch. For very large systems you could go further and store fields in typed arrays (a “structure of arrays”), but for a few hundred particles plain objects are perfectly fast and far more readable.
The animation loop and delta time#
requestAnimationFrame calls your function just before the browser paints, passing a high-resolution timestamp. It runs at the display’s refresh rate and is paused automatically in background tabs, which is why it is the right tool instead of setInterval.
function frame(now) {
// Seconds since the previous frame, clamped to a safe range.
const dt = Math.min(Math.max((now - last) / 1000, 0), 0.05);
last = now;
update(dt);
draw();
rafId = requestAnimationFrame(frame);
}
function start() {
if (running || reducedMotion.matches || !visible) return;
running = true;
last = performance.now();
rafId = requestAnimationFrame(frame);
}
function stop() {
running = false;
cancelAnimationFrame(rafId);
}
Why delta time#
Refresh rates vary between 60, 90, 120, 144 Hz and more, and any device drops frames under load. If you move a particle “two pixels per frame”, it moves twice as fast on a 120 Hz screen and stutters whenever a frame is skipped. Expressing every velocity in pixels per second and multiplying by dt makes motion depend on real time, not on frame count.
Why clamp it#
When the tab regains focus after a minute in the background, the next timestamp could be 60 seconds after the previous one. Without a clamp, every particle would leap across the screen in one step and the spring could even become numerically unstable. Capping dt at 50 ms means a long pause simply resumes smoothly. The lower clamp of 0 guards against the first callback’s timestamp being marginally earlier than the performance.now() value we recorded in start().
Physics: spring, jitter and drag#
We use semi-implicit Euler integration: update velocity from forces first, then update position from the new velocity. It is simple, cheap and stable for small, damped systems like this one.
function update(dt) {
const { rise, spring, jitter } = CONFIG;
const damping = Math.exp(-CONFIG.drag * dt); // frame-rate independent drag
const kick = jitter * Math.sqrt(dt); // random-walk scaling
for (const p of particles) {
const depth = 0.35 + 0.65 * p.z; // near particles move more
p.ay -= rise * depth * dt; // the anchor drifts upwards
p.vx += (p.ax - p.x) * spring * dt; // spring toward the anchor
p.vy += (p.ay - p.y) * spring * dt;
p.vx += (Math.random() * 2 - 1) * kick * depth; // jitter
p.vy += (Math.random() * 2 - 1) * kick * depth;
p.vx *= damping; // drag
p.vy *= damping;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.age += dt;
if (p.age >= p.life || p.y < -20 || p.x < -20 || p.x > width + 20) {
spawn(p);
}
}
}
The spring#
Each particle is attached to an invisible anchor by a spring: the further it wanders, the harder it is pulled back (Hooke’s law, force proportional to distance). Moving the anchor slowly upwards produces a gentle rising drift, while the spring keeps the particle loosely following it rather than traveling in a perfectly straight line.
Jitter as a random walk#
Jitter adds a small random kick every frame, which is what makes dust look like it is floating in moving air. Notice the kick is scaled by Math.sqrt(dt), not dt. Random kicks partially cancel out, so their combined effect grows with the square root of the number of steps. Scaling by the square root of the time step keeps the amount of wandering consistent whether you run 60 or 144 frames per second; scaling by dt would make the effect visibly calmer on fast displays.
Frame-rate independent drag#
A common mistake is v *= 0.98 every frame, which is frame-rate dependent: at 120 Hz the velocity decays twice as often. Exponential decay, v *= Math.exp(-drag * dt), removes the same fraction of velocity per second at any frame rate. With the defaults above, drag and spring form a slightly under-damped system, so particles sway softly instead of oscillating forever or snapping rigidly to their anchors.
| Setting | Increase it to… | Too high looks like… |
|---|---|---|
rise |
Make the dust drift upwards faster | Rain falling in reverse |
spring |
Keep particles closer to their path | Stiff, wobbling motion |
drag |
Calm the motion, kill overshoot | Particles that feel stuck in syrup |
jitter |
Add turbulence and liveliness | Buzzing insects |
density |
Add more particles per area | Visual noise competing with the text |
maxRadius |
Make near particles bigger | Bokeh blobs rather than dust |
Depth: scale, alpha and parallax#
A single depth value per particle produces a convincing sense of three dimensions without any 3D maths. Near particles (high z) are larger, more opaque and move more; far particles are tiny, faint and slow. In update() the depth factor already scales both the rise speed and the jitter. Drawing applies the same idea to size and opacity, multiplied by a lifetime fade:
function draw() {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = `rgb(${CONFIG.color})`;
const span = CONFIG.maxRadius - CONFIG.minRadius;
for (const p of particles) {
const fade = Math.sin(Math.PI * (p.age / p.life)); // 0 → 1 → 0
ctx.globalAlpha = (0.15 + 0.55 * p.z) * fade;
const r = CONFIG.minRadius + span * p.z;
if (r < 1) {
ctx.fillRect(p.x - r, p.y - r, r * 2, r * 2); // tiny: a square is cheaper
} else {
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
Because the particles are small and translucent, draw order barely matters, so we skip depth sorting entirely. If you increase the maximum radius and notice near particles being drawn behind far ones, sort the array by z after spawning instead of every frame.
Batching for larger counts#
Each arc() plus fill() is a separate draw operation. If you push into the high hundreds, quantize opacity into, say, eight buckets and draw each bucket as a single path with one fill(). You trade a tiny amount of opacity precision for far fewer state changes — the same batching principle that makes Canvas charts fast.
Lifecycle and respawning#
A particle is recycled when it reaches the end of its lifetime or drifts beyond a small margin outside the canvas. Recycling simply calls spawn(p) on the same object with initial set to false, so its age restarts at zero.
The lifetime fade, Math.sin(Math.PI * t) where t runs from 0 to 1, is what makes recycling invisible: opacity is zero at birth, peaks halfway and returns to zero at death. A particle is therefore never visible at the moment it teleports to a new position. The out-of-bounds check is a safety net for particles that wander off the edge before their fade completes, and the 20-pixel margin ensures that even the largest particle is fully off-canvas when that happens.
Randomizing the lifetime between 6 and 14 seconds, combined with random starting ages, keeps births and deaths evenly spread so the overall density stays constant and the field never “breathes” in unison.
Reduced motion, visibility and performance#
Honor prefers-reduced-motion#
Some people experience nausea, dizziness or distraction from motion on screen and ask their operating system to reduce it. The prefers-reduced-motion media feature exposes that preference. Our start() function already refuses to run when it matches; in that case resize() draws a single, still frame, so the hero keeps its texture without any movement. We also react if the setting changes while the page is open. (Our WCAG 2.2 guide covers the related success criteria.)
reducedMotion.addEventListener('change', () => {
if (reducedMotion.matches) { stop(); draw(); } else { start(); }
});
// Pause when the hero scrolls out of view.
new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) start(); else stop();
}).observe(canvas);
// Resize with the element, not just the window.
new ResizeObserver(resize).observe(canvas);
// Re-render when devicePixelRatio changes (zoom, moving between monitors).
function watchDpr() {
window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
.addEventListener('change', () => { resize(); watchDpr(); }, { once: true });
}
resize();
watchDpr();
start();
A ResizeObserver on the canvas is better than a window resize listener because the hero can change size without the window changing — for example when fonts load, content wraps differently or a banner is dismissed. It also fires once on observe, so the canvas is sized correctly from the start.
- WCAG 2.2.2 Pause, Stop, Hide (Level A) asks for a way to pause moving content that starts automatically, lasts more than five seconds and appears alongside other content.
- Many people who are bothered by motion never change their OS setting, so a small, keyboard-accessible pause button is the safest choice for anything more than a barely perceptible effect.
- Keep the effect low in contrast so it never reduces the legibility of text placed on top of it.
Keep the main thread free#
Everything in this effect runs on the main thread, which also handles clicks and typing. Keep per-frame work tiny — our loop is a few hundred multiplications and draw calls — and profile on a low-end phone using the browser’s Performance panel. Pausing when off-screen means visitors reading further down the page pay nothing at all. Our Core Web Vitals guide explains why main-thread time matters for Interaction to Next Paint, and if you need a much heavier effect, rendering inside a worker with OffscreenCanvas takes it off the main thread entirely.
- Use
requestAnimationFrameand express all motion per second, multiplied by a clamped delta time. - Exponential drag and square-root-scaled jitter keep the look identical at every refresh rate.
- One depth value can drive size, opacity and speed for convincing parallax.
- Recycle particles with a sine fade to avoid allocations and visible popping.
- Scale the bitmap by a capped devicePixelRatio, resize with
ResizeObserver, pause off-screen and honor reduced motion.
Small code, big atmosphere#
This effect shows how far the web platform goes on its own. There is no framework and no build step required, and the whole thing is small enough to read in one sitting. That is the spirit of our article on modern JavaScript without the bloat: understand the platform, write only the code you need, and measure the cost. From here you can add pointer interaction (a repulsion force near the cursor), color variation by depth, or a WebGL renderer for thousands of particles — the physics stays exactly the same.
Want a hero section with this kind of polish that still loads fast and passes accessibility checks? Our Landing Page Development service combines considered motion design with performance budgets, and for richer interactive experiences see Custom Web Application Development. Get in touch and tell us what you have in mind.
Frequently asked questions
Why use delta time instead of moving particles a fixed amount per frame?
Displays refresh at different rates, commonly 60, 90, 120 or 144 Hz, and frames are sometimes dropped. If you move particles a fixed distance per frame, the animation runs twice as fast on a 120 Hz screen. Multiplying velocities and forces by the elapsed time in seconds keeps the motion identical on every device.
How many particles can a Canvas 2D animation handle?
It depends on particle size, drawing method and device, but a subtle background effect rarely needs more than a few hundred particles. Scale the count to the canvas area, cap it, and test on a low-end phone; if you need tens of thousands of particles, WebGL is the better tool.
Does a decorative canvas animation hurt Core Web Vitals?
A canvas element is not normally reported as the Largest Contentful Paint element, and it does not cause layout shift if it is absolutely positioned. The main risk is main-thread time, which can delay interactions and hurt INP, so keep per-frame work small and pause the animation when it is off-screen.
Is honoring prefers-reduced-motion enough for accessibility?
It is an essential first step, but WCAG 2.2.2 Pause, Stop, Hide also asks for a way to pause automatically moving content that lasts more than five seconds alongside other content. For subtle decorative effects, providing a visible pause control as well is the safest approach.
