Procedural Animation with Simplex Noise

Frontend Engineering advanced Building with AI ~22 min read

Overview

Procedural animation uses mathematical functions rather than keyframes to generate motion. Instead of defining explicit start and end states for every animated element, the system computes positions, colors, and lifetimes from noise functions evaluated at each frame. Simplex noise (an improved variant of Perlin noise) is the workhorse behind this approach: it produces smooth, continuous pseudo-random values that vary gradually over space and time, creating organic-looking motion that never repeats exactly. When applied to hundreds of particles simultaneously, simplex noise generates complex emergent behavior — swirling vortices, drifting clouds, flowing streams — from a single compact algorithm.

An internal component library uses this technique to render a vortex particle effect with 700+ particles on an HTML5 Canvas. Each particle has a position, velocity, time-to-live (TTL), and a hue derived from its position in the noise field. The noise function drives the angular velocity of each particle, creating a spiraling vortex pattern where particles appear to be caught in a swirling current. As particles expire (TTL reaches zero), they are recycled to their spawn position with fresh TTL values, creating a continuous flow effect without ever allocating new objects. The hue of each particle is interpolated based on its distance from the vortex center and its noise-sampled angle, producing smooth color gradients that shift over time.

This approach is fundamentally different from sprite-based or physics-engine particle systems. There is no collision detection, no force accumulation, and no springs. The entire visual complexity emerges from evaluating noise2D(x * scale, y * scale) at each particle’s position every frame and using the returned value (-1 to 1) to steer the particle’s heading. The result is a lightweight system that runs at 60fps with 700+ particles on mid-range hardware using only 2D canvas — no WebGL required.

Core Concepts

Simplex Noise

Simplex noise is a gradient noise function invented by Ken Perlin as an improvement over his original Perlin noise. It produces smooth, continuous pseudo-random values in N dimensions. For 2D use, noise2D(x, y) returns a value between -1 and 1 that changes gradually as x and y change. The key property is coherence: nearby inputs produce nearby outputs, creating smooth gradients rather than random static. By feeding particle positions (scaled down) into the noise function, each particle gets a steering angle that is similar to its neighbors’, producing coordinated flow patterns. The simplex-noise npm package provides a fast, zero-dependency implementation.

Particle TTL (Time-to-Live) Management

Each particle is assigned a TTL value on spawn — the number of frames it will live before being recycled. When TTL reaches zero, the particle resets to its spawn position with a fresh TTL. This creates a continuous stream effect without memory allocation: the particle pool is a fixed-size array allocated once at initialization. TTL also drives opacity — particles fade out as they approach death (opacity = ttl / maxTTL), creating soft edges at the vortex boundary. Staggering initial TTL values across the pool prevents all particles from resetting simultaneously, which would cause a visible “pulse” artifact.

Hue Interpolation

Rather than assigning particles a fixed color, the hue is computed dynamically from the noise field. The noise value at a particle’s position is mapped to a hue range (e.g., 200-320 degrees in HSL, spanning blue through magenta). As particles move through the noise field, their hue shifts smoothly. Combined with the TTL-driven opacity fade, this creates luminous trails that transition from cool blues at the vortex center to warm magentas at the edges. The hsl() CSS color function makes this trivial on canvas: ctx.fillStyle = `hsl(${hue}, 80%, 55%)`.

Noise-Driven Steering

The core animation loop samples the noise function at each particle’s scaled position to produce a steering angle. This angle is added to the particle’s current heading, and the particle advances by a fixed step size in that direction. The noise scale parameter controls the granularity of the flow field: small scale values (0.001-0.005) produce broad, sweeping curves; larger values (0.01-0.05) produce tighter, more turbulent spirals. By adding a time component to one of the noise dimensions — noise2D(x * scale, y * scale + time * speed) — the flow field itself evolves over time, preventing the animation from settling into a static pattern.

How It Works

Setting Up the Noise Generator

The simplex-noise package provides a createNoise2D factory that returns a pure function. This function is called thousands of times per frame, so it must be fast — the simplex algorithm evaluates in O(1) time with no allocations.

import { createNoise2D } from "simplex-noise";

// Create a seeded noise function — same seed = same pattern
const noise2D = createNoise2D();

// Sample noise at a position (returns -1 to 1)
const value = noise2D(x * 0.003, y * 0.003);

// Map to an angle (0 to 2PI)
const angle = (value + 1) * Math.PI;

Particle Data Structure

Each particle is a plain object in a pre-allocated array. Using a struct-of-arrays layout (separate typed arrays for x, y, vx, vy, ttl, hue) would be faster for very large counts, but for 700 particles the object approach is clearer and performs well.

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  ttl: number;
  maxTTL: number;
  hue: number;
  size: number;
}

function createParticle(
  centerX: number,
  centerY: number,
  spread: number
): Particle {
  const angle = Math.random() * Math.PI * 2;
  const radius = Math.random() * spread;
  return {
    x: centerX + Math.cos(angle) * radius,
    y: centerY + Math.sin(angle) * radius,
    vx: 0,
    vy: 0,
    ttl: Math.floor(Math.random() * 200) + 100,
    maxTTL: 300,
    hue: 0,
    size: Math.random() * 2.5 + 0.5,
  };
}

// Pre-allocate the entire particle pool
const PARTICLE_COUNT = 750;
const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () =>
  createParticle(canvas.width / 2, canvas.height / 2, 150)
);

The Vortex Animation Loop

Each frame, every particle samples the noise field at its position to get a steering angle, updates its velocity, advances its position, decrements its TTL, and is drawn with a hue and opacity derived from its state. When TTL expires, the particle is recycled to the spawn region.

const NOISE_SCALE = 0.003;
const STEP_SIZE = 1.5;
const HUE_MIN = 200;  // blue
const HUE_MAX = 320;  // magenta
const VORTEX_STRENGTH = 0.6;

let time = 0;

function animate() {
  // Semi-transparent fill for trail effect
  ctx.fillStyle = "rgba(15, 17, 23, 0.08)";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  const cx = canvas.width / 2;
  const cy = canvas.height / 2;
  time += 0.002;

  for (let i = 0; i < particles.length; i++) {
    const p = particles[i];

    // Sample noise at particle position
    const noiseVal = noise2D(
      p.x * NOISE_SCALE,
      p.y * NOISE_SCALE + time
    );

    // Convert noise to steering angle
    const angle = noiseVal * Math.PI * 2;

    // Add vortex rotation toward center
    const dx = cx - p.x;
    const dy = cy - p.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    const vortexAngle = Math.atan2(dy, dx) + Math.PI / 2;

    // Blend noise steering with vortex pull
    const finalAngle = angle + vortexAngle * VORTEX_STRENGTH;

    // Update velocity with smoothing
    p.vx = p.vx * 0.92 + Math.cos(finalAngle) * STEP_SIZE * 0.08;
    p.vy = p.vy * 0.92 + Math.sin(finalAngle) * STEP_SIZE * 0.08;
    p.x += p.vx;
    p.y += p.vy;

    // Compute hue from noise field
    const hueNoise = noise2D(
      p.x * NOISE_SCALE * 2,
      p.y * NOISE_SCALE * 2
    );
    p.hue = HUE_MIN + ((hueNoise + 1) / 2) * (HUE_MAX - HUE_MIN);

    // TTL and opacity
    p.ttl--;
    const lifeRatio = p.ttl / p.maxTTL;
    const opacity = Math.min(lifeRatio * 3, 1.0) * 0.7;

    // Draw particle
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
    ctx.fillStyle = `hsla(${p.hue}, 80%, 55%, ${opacity})`;
    ctx.fill();

    // Recycle dead particles
    if (p.ttl <= 0 || dist > canvas.width * 0.6) {
      resetParticle(p, cx, cy);
    }
  }

  requestAnimationFrame(animate);
}

function resetParticle(p: Particle, cx: number, cy: number) {
  const angle = Math.random() * Math.PI * 2;
  const radius = Math.random() * 80;
  p.x = cx + Math.cos(angle) * radius;
  p.y = cy + Math.sin(angle) * radius;
  p.vx = 0;
  p.vy = 0;
  p.ttl = Math.floor(Math.random() * 200) + 100;
  p.size = Math.random() * 2.5 + 0.5;
}

Integrating with React

In the component library’s codebase, the vortex effect runs inside a React component that manages the canvas lifecycle — creating the 2D context on mount, starting the animation loop, and cleaning up on unmount. The useRef hook holds the animation frame ID for cancellation.

import { useEffect, useRef } from "react";
import { createNoise2D } from "simplex-noise";

export function VortexBackground() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const frameRef = useRef<number>(0);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    // Match canvas resolution to display size
    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.getBoundingClientRect();
    canvas.width = rect.width * dpr;
    canvas.height = rect.height * dpr;
    ctx.scale(dpr, dpr);

    const noise2D = createNoise2D();
    const particles = initParticles(750, rect.width, rect.height);

    function loop() {
      updateAndDraw(ctx!, particles, noise2D, rect.width, rect.height);
      frameRef.current = requestAnimationFrame(loop);
    }

    frameRef.current = requestAnimationFrame(loop);

    return () => {
      cancelAnimationFrame(frameRef.current);
    };
  }, []);

  return (
    <canvas
      ref={canvasRef}
      style={{
        position: "absolute",
        inset: 0,
        width: "100%",
        height: "100%",
        zIndex: -1,
      }}
    />
  );
}

Practical Applications

The component library uses the vortex particle effect as a hero background on the search platform’s landing page. The animation draws 750 particles swirling in a vortex pattern centered behind the main headline. The hue interpolation shifts from deep blue at the vortex core (representing focused search queries) to magenta at the outer edges (representing the breadth of search results). The effect communicates the idea of data flowing through a search pipeline — information being drawn inward, processed, and dispersed — without any explicit iconography or illustration.

The particle count was tuned to 750 specifically to balance visual density with performance. On mobile devices (tested on iPhone 13 and Pixel 7), the effect drops to 400 particles via a matchMedia check for (max-width: 768px), maintaining 60fps on lower-powered GPUs. The canvas uses a semi-transparent fill per frame (rgba(15, 17, 23, 0.08)) rather than clearing entirely, which creates the glowing trail effect as particles leave afterimages that fade over roughly 12 frames.

A secondary application is the loading state for long-running semantic search queries. When a search takes more than 500ms, a small inline vortex animation (200 particles in a 120x120 canvas) appears next to the search bar. The noise speed parameter increases as the estimated progress grows, making the vortex spin faster as the search nears completion. This provides a more engaging loading indicator than a standard spinner while reinforcing the visual language of the landing page.

Implementation Patterns

Adaptive Particle Count

The particle count should scale with device capability. A simple heuristic uses the canvas area and device pixel ratio to decide how many particles the system can handle at 60fps.

function getParticleCount(
  canvasWidth: number,
  canvasHeight: number
): number {
  const isMobile = window.matchMedia("(max-width: 768px)").matches;
  const dpr = window.devicePixelRatio || 1;
  const pixels = canvasWidth * canvasHeight * dpr * dpr;

  if (isMobile) return 400;
  if (pixels > 4_000_000) return 1000;  // Large desktop
  if (pixels > 2_000_000) return 750;   // Standard desktop
  return 500;                             // Small window
}

// Optional: reduce on low FPS
let lastFrameTime = performance.now();
let consecutiveSlowFrames = 0;

function checkPerformance(now: number) {
  const delta = now - lastFrameTime;
  lastFrameTime = now;

  if (delta > 20) {  // Below 50fps
    consecutiveSlowFrames++;
    if (consecutiveSlowFrames > 30) {
      // Reduce particle count by 20%
      particles.length = Math.floor(particles.length * 0.8);
      consecutiveSlowFrames = 0;
    }
  } else {
    consecutiveSlowFrames = 0;
  }
}

Noise Layering (Fractal Brownian Motion)

A single noise sample produces smooth but uniform flow. Layering multiple octaves of noise at different frequencies and amplitudes (fractal Brownian motion / fBm) creates more complex, natural-looking turbulence.

function fbm(
  noise2D: (x: number, y: number) => number,
  x: number,
  y: number,
  octaves: number = 4
): number {
  let value = 0;
  let amplitude = 1;
  let frequency = 1;
  let maxValue = 0;

  for (let i = 0; i < octaves; i++) {
    value += noise2D(x * frequency, y * frequency) * amplitude;
    maxValue += amplitude;
    amplitude *= 0.5;   // Each octave is half the amplitude
    frequency *= 2.0;   // Each octave is double the frequency
  }

  return value / maxValue;  // Normalize to [-1, 1]
}

// Usage in the animation loop
const steeringNoise = fbm(noise2D, p.x * NOISE_SCALE, p.y * NOISE_SCALE + time, 3);
const angle = steeringNoise * Math.PI * 2;

Offscreen Canvas for Background Threading

For maximum performance, the entire particle simulation and rendering can be moved to a Web Worker using OffscreenCanvas. This frees the main thread for React rendering and user interactions.

// main.ts — transfer canvas control to worker
const canvas = document.getElementById("vortex") as HTMLCanvasElement;
const offscreen = canvas.transferControlToOffscreen();

const worker = new Worker(
  new URL("./vortex-worker.ts", import.meta.url),
  { type: "module" }
);

worker.postMessage({ canvas: offscreen, dpr: devicePixelRatio }, [offscreen]);

// vortex-worker.ts — runs entirely off main thread
self.onmessage = (e: MessageEvent) => {
  const { canvas, dpr } = e.data as {
    canvas: OffscreenCanvas;
    dpr: number;
  };
  const ctx = canvas.getContext("2d")!;
  ctx.scale(dpr, dpr);

  const noise2D = createNoise2D();
  const particles = initParticles(750, canvas.width / dpr, canvas.height / dpr);

  function loop() {
    updateAndDraw(ctx, particles, noise2D, canvas.width / dpr, canvas.height / dpr);
    requestAnimationFrame(loop);
  }

  requestAnimationFrame(loop);
};

Common Pitfalls

Further Reading