Smoke
A 2D canvas grows semi-transparent circular smoke particles with sinusoidal lateral drift and radial expansion — pure rAF, zero dependencies.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Atmosphere has 57 effects, including 10 free. Explore the category →
3 usage examples



How it works
The IIFE initialises the canvas via setupCanvas("psSmoke") — the function reads the parent's getBoundingClientRect() to set width/height, then returns {canvas, ctx, w, h}. An array e accumulates live particles. The requestAnimationFrame loop repaints every frame: clearRect then fillRect in #0a0a0f erase the previous frame, then all particles are updated and drawn before expired ones are removed by reverse splice.
Each frame, if Math.random() > 0.3 (70 % probability), a particle is pushed near the bottom centre — x = w/2 ± 5 px, y = h − 30. Its properties: horizontal drift vx = ±0.25 px/frame, upward velocity vy between −0.5 and −2.0 px/frame, life = 1, decay between 0.003 and 0.008 (lifespan 125–333 frames), initial size 4–12 px.
Each frame, the position evolves as x += vx + 0.3 × sin(0.02 × y) — the sinusoidal component keyed to Y produces a progressive undulating drift that simulates natural smoke turbulence. y += vy (ascent), life -= decay, size += 0.15 (radial expansion). Each particle is a filled arc() in rgba(180,180,190, 0.15 × life): 15 % max opacity at birth, invisible at death.
The code handles neither prefers-reduced-motion, nor IntersectionObserver, nor canvas resizing after init. The IIFE targets a single canvas by fixed id (id="psSmoke") — for multiple simultaneous columns, the particle loop must be extracted into a function parameterised by id.
Accessibility
- prefers-reduced-motion not handled: the code does not check this OS preference — the animation loops indefinitely regardless of accessibility settings. Fix: add
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;before launching the rAF. - The canvas carries neither
aria-hidden="true"nor arolein the source HTML — addaria-hidden="true"for any decorative use to exclude the element from the accessibility tree. - No pause/stop control is exposed — WCAG 2.2.2 requires a pause mechanism for any auto-playing animation lasting more than 5 seconds.
- No keyboard interaction by design (purely decorative effect) — suitable as an ambient background provided the canvas is hidden from the accessibility tree via
aria-hidden. - Low particle opacity (
rgba(180,180,190)at 15 % max) — the smoke does not obscure text content overlaid on a dark background, limiting visual noise for contrast-sensitive users.
Browser compatibility
Requires Canvas 2D Context and requestAnimationFrame — supported in all modern browsers. Zero external dependencies.
Without Canvas support, the container renders empty on a dark background — no fatal JS error, any overlaid text content remains readable.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="demo-preview">
<div class="ps-canvas-wrap">
<canvas class="ps-canvas" id="psSmoke" width="392" height="280"
aria-hidden="true"></canvas>
</div>
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
| rgba(180,180,190, ...) | grey-blue | RGB values in ctx.fillStyle — use 160,120,80 for amber smoke, 100,130,180 for blue smoke, 200,60,40 for reddish volcanic vapour. |
| 0.15 * a.life | 0.15 | Max opacity multiplier — increase to 0.35 for dense visible smoke, drop to 0.06 for barely perceptible background wisps. |
| Math.random() > .3 | 0.3 (70 % spawn) | Spawn threshold per frame — raise to 0.8 (20 % chance) for sparse distant smoke, lower to 0.05 (95 %) for a dense continuous column. |
| vy: 1.5 * -Math.random() - .5 | −0.5 to −2.0 px/frame | Rise speed — multiply the 1.5 coefficient by 0.4 for lazy smoke, by 2.5 for brisk vapour. |
| .3 * Math.sin(.02 * a.y) | 0.3 px amplitude | Sinusoidal drift amplitude — set to 0 for a straight vertical plume, to 1.2 for a highly serpentine, twisting smoke. |
| size: 8 * Math.random() + 4 | 4–12 px | Initial particle size — use 20 * Math.random() + 10 for large billowing clouds, 3 * Math.random() + 1 for precise thin wisps. |
| size += .15 | 0.15 px/frame | Radial expansion rate — increase to 0.4 for smoke that billows outward quickly, set to 0 for fixed-size floating mist particles. |
FAQ
getElementById("psSmoke") — the IIFE can only run once. For multiple columns, extract the particle loop into a function taking an id parameter and call it for each canvas. The internal logic is fully independent: each call maintains its own particle array and its own rAF.requestAnimationFrame launch: if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;. For a degraded fallback (a single static frame), draw the initial particles without starting the loop. Since Chrome 76+, the MediaQueryList emits a change event — you can react in real time if the user updates the preference mid-session.fillStyle = `rgba(180,180,190,${i})` with fillStyle = `hsla(${200 + (a.life * 20) | 0},20%,75%,${i})` — the particle is born cool (HSL 220, bluish) and ages toward neutral grey (HSL 200) as it rises, simulating progressive cooling.