Animated Plasma — TypeScript Tutorial

Learn how to build a real-time plasma effect using HTML5 Canvas and TypeScript. Includes live demo code and explanations.

Live demo

TypeScript source

This TypeScript code computes a plasma value per-pixel using several sine functions and maps the result into RGB. Below is a clear, commented TS implementation you can copy into your project or compile with tsc / esbuild / vite.

// TypeScript: plasma.ts
const canvas = document.getElementById('plasma') as HTMLCanvasElement;
const ctx = canvas.getContext('2d')!;
let width = canvas.width;
let height = canvas.height;

const scaleInput = document.getElementById('scale') as HTMLInputElement;
const speedInput = document.getElementById('speed') as HTMLInputElement;
const shiftInput = document.getElementById('shift') as HTMLInputElement;
const pauseBtn = document.getElementById('pause') as HTMLButtonElement;
const resetBtn = document.getElementById('reset') as HTMLButtonElement;

let running = true;
let time = 0;

// Resize helper to keep internal buffer size matching canvas pixel size
function resize() {
  const dpr = Math.max(1, window.devicePixelRatio || 1);
  const rect = canvas.getBoundingClientRect();
  width = Math.floor(rect.width * dpr);
  height = Math.floor(rect.height * dpr);
  canvas.width = width;
  canvas.height = height;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();

// Fast sin/cos helpers
const sin = Math.sin;
const cos = Math.cos;
const PI = Math.PI;

// Color mapping: use smooth polychromatic palette
function palette(t: number) {
  // t in [0,1) -> return [r,g,b] in [0,255]
  const r = Math.floor(128 + 127 * sin(2.0 * PI * (t) + 0.0));
  const g = Math.floor(128 + 127 * sin(2.0 * PI * (t) + 2.0));
  const b = Math.floor(128 + 127 * sin(2.0 * PI * (t) + 4.0));
  return [r, g, b];
}

// Compute a single frame into an ImageData buffer
function renderFrame(tSec: number) {
  const image = ctx.createImageData(width, height);
  const data = image.data;
  const scale = parseFloat(scaleInput.value);
  const speed = parseFloat(speedInput.value);
  const shift = parseFloat(shiftInput.value);

  // Precompute commonly used values
  const timeFactor = tSec * speed;
  let idx = 0;

  // Loop over pixels
  for (let y = 0; y < height; y++) {
    const ny = (y / height - 0.5) * scale;
    for (let x = 0; x < width; x++) {
      const nx = (x / width - 0.5) * scale;

      // Combine multiple sine waves for richness
      const v1 = sin( (nx * 3.0) + timeFactor );
      const v2 = sin( (ny * 3.0) + timeFactor * 0.7 );
      const v3 = sin( (nx + ny) * 2.0 + sin(timeFactor * 0.5) );
      const v4 = sin( Math.hypot(nx * 1.5, ny * 1.5) * 3.0 - timeFactor );

      // Mix values to produce final scalar
      const mix = 0.5 * (v1 + v2) + 0.25 * v3 + 0.25 * v4;

      // Normalize mix to [0,1]
      const normalized = (mix + 1) * 0.5;

      // Apply color palette and optional shift
      const t = (normalized + (shift / (2 * PI))) % 1;
      const [r,g,b] = palette(t);

      // Write pixel
      data[idx++] = r;
      data[idx++] = g;
      data[idx++] = b;
      data[idx++] = 255;
    }
  }

  ctx.putImageData(image, 0, 0);
}

// Animation loop using requestAnimationFrame
let last = performance.now();
function animate(now: number) {
  if (!running) {
    last = now;
    requestAnimationFrame(animate);
    return;
  }
  const dt = (now - last) / 1000;
  last = now;
  time += dt;

  renderFrame(time);

  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

// Controls
pauseBtn.addEventListener('click', () => {
  running = !running;
  pauseBtn.textContent = running ? 'Pause' : 'Resume';
});
resetBtn.addEventListener('click', () => { time = 0; });
      

How it works

  1. For each pixel we map its canvas coordinates into a small continuous domain centered at zero so patterns tile and scale nicely.
  2. We evaluate several sine-based functions of x, y and radius to create interference patterns that evolve over time.
  3. We combine those values into a single scalar and map that scalar through a smooth color palette.
  4. We write the resulting RGB into an ImageData buffer and paint it once per animation frame for maximum performance.

Performance tips

Optional: WebGL / shader idea

Same math can be ported to a fragment shader where x,y are normalized gl_FragCoord and sine combinations are evaluated per-fragment for hardware-accelerated speed.

JavaScript tutorials:

Spinning squares - visual effect (25 lines)

Oldschool fire effect (20 lines)

Fireworks (60 lines)

Animated fractal (32 lines)

Physics engine for beginners