---
name: smoke-effect
description: Use when you want "Atmospheric, cinematic, mysterious" - Renders drifting smoke or mist using canvas/WebGL.
---

# Smoke Effect

> **Category:** Advanced WebGL / Canvas  -  **Personality:** Atmospheric, cinematic, mysterious
>
> **Best use:** Events, gaming, cinematic pages
>
> **Ratings:** Professional 2/5 - Casual 3/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 5/5

## What it is

Volumetric drifting smoke/mist rendered on a 2D `<canvas>`: a few dozen **soft, pale radial-gradient "puffs"** are spawned near the base and rise while curling, expanding and fading, so the overlapping low-alpha sprites build up into believable haze. It's the cheap, robust way to get a moody, cinematic atmosphere (a game reveal, an event teaser, a horror/fantasy hero) without a WebGL fluid solver. The pointer parts and drags the fog like a hand through smoke. The "blur" is baked into the sprite (a wide soft gradient) rather than applied per-frame, which is what keeps it fast.

## Dependencies / CDN

None - vanilla JS + canvas 2D. The demo only loads display/body fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@700;900&family=Hanken+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
```

## HTML

The canvas sits at the back; foreground content and a legibility scrim sit above it.

```html
<section class="sm-stage">
  <canvas class="sm-smoke" aria-hidden="true"></canvas>
  <div class="sm-ember" aria-hidden="true"></div>   <!-- warm key-light glow -->
  <div class="sm-scrim" aria-hidden="true"></div>   <!-- vignette + darken for legibility -->
  <div class="sm-content">
    <h2 class="sm-title">ASHEN <em>VEIL</em></h2>
    <p class="sm-tagline">A lantern. A drowned city. And a fog that keeps what it takes.</p>
    <button class="sm-btn sm-btn--ember">Watch the reveal</button>
  </div>
</section>
```

## CSS

The effect is JS; CSS just frames the stage, layers the scrim, and guarantees the headline reads over the smoke.

```css
.sm-stage{position:relative;border-radius:28px;overflow:hidden;isolation:isolate;
  background:radial-gradient(135% 105% at 50% -12%,#12161e,#0a0d13 42%,#05070a 100%)}
.sm-smoke{position:absolute;inset:0;z-index:0;display:block;width:100%;height:100%}

/* legibility: vignette + top/bottom darken so text always reads over the fog */
.sm-scrim{position:absolute;inset:0;z-index:2;pointer-events:none;
  background:
    radial-gradient(122% 82% at 50% 34%,transparent 38%,rgba(4,6,9,.58) 100%),
    linear-gradient(180deg,rgba(4,6,9,.66) 0%,transparent 27%,transparent 60%,rgba(4,6,9,.5) 100%)}

.sm-content{position:relative;z-index:4;max-width:1080px;margin:0 auto;padding:24px 34px 30px;
  min-height:690px;display:flex;flex-direction:column;color:#eef2f6}
.sm-title{font-family:'Cinzel',serif;font-weight:900;font-size:clamp(46px,9.2vw,118px);
  letter-spacing:.06em;text-shadow:0 2px 44px rgba(0,0,0,.6)}
.sm-title em{font-style:normal;background:linear-gradient(180deg,#ffe6bf,#ff9a4d 58%,#f2762e);
  -webkit-background-clip:text;background-clip:text;color:transparent}
```

## JavaScript

The whole engine: a reusable soft-puff sprite, a particle field that rises + curls, pointer disturbance, DPR sizing, an off-screen/visibility pause, and a static reduced-motion fallback.

```js
var stage=document.querySelector('.sm-stage'), canvas=document.querySelector('.sm-smoke');
var ctx=canvas.getContext('2d');
var reduce=matchMedia('(prefers-reduced-motion: reduce)').matches;

// 1) Pre-render ONE soft puff per tint — the soft gradient IS the "blur" (cheap).
var TINTS=['178,200,224','150,178,206','198,184,208','226,196,158'];
var sprites=TINTS.map(function(rgb){
  var s=128,c=document.createElement('canvas');c.width=c.height=s;var g=c.getContext('2d');
  var grad=g.createRadialGradient(s/2,s/2,0,s/2,s/2,s/2);
  grad.addColorStop(0,'rgba('+rgb+',0.50)');grad.addColorStop(0.4,'rgba('+rgb+',0.20)');
  grad.addColorStop(1,'rgba('+rgb+',0)');g.fillStyle=grad;g.fillRect(0,0,s,s);return c;
});

var W,H,dpr,parts=[],time=0;
var pointer={x:-1e3,y:-1e3,vx:0,vy:0,r:160,active:false};

function reset(p,initial){
  p.x=Math.random()*W; p.r=38+Math.random()*62; p.grow=p.r*(0.06+Math.random()*0.12);
  p.buoy=-(18+Math.random()*30); p.vx=(Math.random()-0.5)*10; p.vy=p.buoy;
  p.life=7+Math.random()*6; p.rot=Math.random()*6.283; p.vr=(Math.random()-0.5)*0.3;
  p.sx=0.85+Math.random()*0.5; p.sy=0.80+Math.random()*0.45;
  p.alpha=0.42+Math.random()*0.4; p.seed=Math.random()*6.283;
  p.sprite=(Math.random()<0.42?0:Math.random()<0.55?1:Math.random()<0.7?2:3);
  if(initial){p.y=Math.random()*H;p.age=Math.random()*p.life*0.85;}      // pre-fill field
  else       {p.y=H*0.82+Math.random()*H*0.25;p.age=0;}                  // respawn at base
}
function build(){var n=Math.round(Math.max(56,Math.min(140,W/9)));parts=[];for(var i=0;i<n;i++){var p={};reset(p,true);parts.push(p);}}

function resize(){
  var r=stage.getBoundingClientRect(); dpr=Math.min(devicePixelRatio||1,2);
  W=Math.round(r.width); H=Math.round(r.height);
  canvas.width=Math.round(W*dpr); canvas.height=Math.round(H*dpr);
  canvas.style.width=W+'px'; canvas.style.height=H+'px';
  ctx.setTransform(dpr,0,0,dpr,0,0); pointer.r=Math.max(130,Math.min(W,H)*0.30);
  build(); if(reduce) renderStatic();
}
function renderStatic(){ // reduced-motion: a calm, fixed haze drawn once
  ctx.clearRect(0,0,W,H);
  [[0.16,0.96,270,0],[0.5,1.04,340,1],[0.84,0.97,270,2],[0.5,0.64,180,2]].forEach(function(b){
    ctx.globalAlpha=0.5; ctx.drawImage(sprites[b[3]],b[0]*W-b[2],b[1]*H-b[2],b[2]*2,b[2]*2);
  }); ctx.globalAlpha=1;
}

function step(dtMs){
  var dt=Math.min(dtMs,33)/1000; time+=dtMs; var t=time*0.001;
  pointer.vx*=0.82; pointer.vy*=0.82;                 // cursor wake fades
  ctx.clearRect(0,0,W,H);
  for(var i=0;i<parts.length;i++){var p=parts[i];
    var ax=Math.sin(p.y*0.012+t*0.6+p.seed)*16+Math.cos(p.y*0.025-t*0.45+p.seed*1.7)*9; // pseudo-curl
    p.vx+=ax*dt; p.vx-=p.vx*0.9*dt; p.vy+=(p.buoy-p.vy)*0.8*dt;
    if(pointer.active){var dx=p.x-pointer.x,dy=p.y-pointer.y,R=pointer.r,d2=dx*dx+dy*dy;
      if(d2<R*R){var d=Math.sqrt(d2)||1,f=1-d/R;
        p.vx+=(dx/d)*f*f*130*dt; p.vy+=(dy/d)*f*f*130*dt;   // part the fog
        p.vx+=pointer.vx*f*12*dt; p.vy+=pointer.vy*f*12*dt; // drag along the wake
      }}
    var sp=Math.sqrt(p.vx*p.vx+p.vy*p.vy); if(sp>260){p.vx=p.vx/sp*260;p.vy=p.vy/sp*260;}
    p.x+=p.vx*dt; p.y+=p.vy*dt; p.r+=p.grow*dt; p.age+=dt; p.rot+=p.vr*dt;
    if(p.age>=p.life||p.y<-p.r){reset(p,false);continue;}
    var k=p.age/p.life, a=k<0.18?k/0.18:(k>0.55?(1-k)/0.45:1);
    ctx.globalAlpha=Math.max(0,Math.min(1,a))*p.alpha;
    ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(p.rot); ctx.scale(p.sx,p.sy);
    ctx.drawImage(sprites[p.sprite],-p.r,-p.r,p.r*2,p.r*2); ctx.restore();
  }
  ctx.globalAlpha=1;
}

// run loop, paused off-screen / when the tab is hidden
var running=false,raf=0,last=0,inView=false;
function frame(ts){if(!running)return;var dt=last?ts-last:16;last=ts;step(dt);raf=requestAnimationFrame(frame);}
function start(){if(running||reduce)return;running=true;last=0;raf=requestAnimationFrame(frame);}
function stop(){running=false;cancelAnimationFrame(raf);}
function sync(){if(reduce)return;(inView&&!document.hidden)?start():stop();}

stage.addEventListener('pointermove',function(e){var r=stage.getBoundingClientRect();
  var nx=e.clientX-r.left,ny=e.clientY-r.top;
  if(pointer.active){pointer.vx=Math.max(-60,Math.min(60,nx-pointer.x));pointer.vy=Math.max(-60,Math.min(60,ny-pointer.y));}
  pointer.x=nx;pointer.y=ny;pointer.active=true;});
stage.addEventListener('pointerleave',function(){pointer.active=false;pointer.vx=pointer.vy=0;});
new ResizeObserver(resize).observe(stage);
new IntersectionObserver(function(es){inView=es[0].isIntersecting;sync();},{threshold:0.04}).observe(stage);
document.addEventListener('visibilitychange',sync);
resize();
```

## How it works

- **Soft sprites = the smoke + the blur.** Each puff is a 128px radial gradient that fades to transparent. Drawing many of them at low alpha (`globalAlpha`) with `source-over` lets the pale colour accumulate over the dark stage into volumetric mist. Because the softness lives in the sprite, there's no expensive per-frame `ctx.filter = 'blur()'`.
- **Rising + curling.** Each particle has an upward buoyancy (`vy`), and a cheap **pseudo-curl** force (two layered `sin`/`cos` terms keyed on the particle's `y` and time) nudges `vx` left/right so plumes weave instead of rising straight. Particles also grow (`p.r += grow`) and slowly rotate, then fade in/out across their lifetime and respawn at the base.
- **Pointer disturbance.** Particles within the pointer radius get pushed radially **outward** (parting the fog) plus advected along a clamped, decaying **cursor velocity** (`pointer.vx/vy`) so a swipe leaves a curling wake. A speed clamp keeps it from exploding.
- **DPR-aware.** The backing store is sized `cssPx * devicePixelRatio` (capped at 2) and the context is `setTransform`-scaled, so everything is drawn in CSS pixels but renders crisp on Retina without quadrupling the fill on 3x phones.

## Customization

- **Density / softness:** particle count is `clamp(56, W/9, 140)` — raise the cap for thicker fog; widen the sprite's middle colour stop for softer, milkier puffs.
- **Colour & mood:** edit `TINTS` (cool steel-blue here for mist). Warm greys read as campfire/ash; a single green tint reads as toxic/eerie. The warm `.sm-ember` CSS layer (`mix-blend-mode:screen`) tints the base — recolour or remove it.
- **Direction:** flip `buoy` positive to sink the smoke (dry-ice / fog rolling down); change the curl frequencies/amplitudes for lazy vs. agitated motion.
- **Reach of the cursor:** `pointer.r` and the `*130` / `*12` gains control how forcefully it parts vs. drags the fog.

## Accessibility & performance

- **Reduced motion:** `prefers-reduced-motion` skips the RAF loop entirely and paints `renderStatic()` once — a calm, motionless haze, so the atmosphere survives without movement.
- **Off-screen & hidden pause:** an `IntersectionObserver` stops the loop when the stage scrolls out of view and a `visibilitychange` listener stops it on a hidden tab — zero GPU/CPU when nobody's looking.
- **Fill-rate is the cost.** Large overlapping alpha sprites are fill-rate heavy (hence the 5/5 performance rating). Keep the particle count modest, cap DPR at 2, and never animate a real blur filter — pre-blur the sprite instead.
- The canvas is decorative: `aria-hidden="true"`, and a scrim/vignette plus a text-shadow keep the headline contrast safe over the brightest fog.

## Gotchas

- **`dt` spikes after a tab switch** make particles teleport; clamp the frame delta (`Math.min(dtMs,33)`) and reset `last` on start.
- **The pointer wake will run away** if you inject velocity every frame without bounds — clamp the per-move delta (`±60`), decay it (`*0.82`), and clamp each particle's speed.
- **Set `canvas.style.width/height` AND `.width/.height`.** The attributes are the backing-store pixels; the style is the CSS box. Forgetting the style makes a DPR canvas render double-size.
- **Nothing behind = nothing to read.** Smoke only looks like smoke over a dark, slightly varied background; on a flat light page the pale puffs vanish. Pair it with a deep vignette.
- **Don't fade-trail by skipping `clearRect`.** Drawing a translucent black rect each frame instead of clearing looks smoky but muddies text legibility and slowly accumulates banding — clear fully and rely on the soft sprites.
