Slavko Babić
All experiments

March 15, 2026

A flow field on a canvas

A few hundred particles drifting along a slowly-evolving vector field, leaving fading trails — a tiny, dependency-free canvas sketch.

The animations are illustrative — built for presentation purposes.

I like flow fields because they’re almost nothing and yet they look alive. Give every point on the plane an angle, drop a few hundred particles on it, and let each one step in the direction of the field beneath it. Fade the previous frame a little each tick so the paths leave trails, and the whole thing starts to breathe.

The “field” here is deliberately cheap — no Perlin noise, just a few layered sines of position and time. It’s not physically meaningful; it just has to be smooth, so neighbouring particles agree on roughly where to go and the flow reads as coherent rather than random. A slow time term keeps the field drifting, so the picture never settles.

Two details make it feel finished rather than like a demo. It reads the theme accent straight from CSS, so it recolours itself in light and dark without knowing anything about the palette. And under prefers-reduced-motion it skips the animation entirely and draws a single static frame of the field — short segments pointing the way the current flows — so the idea survives even when the motion doesn’t. About a kilobyte of script, no libraries, and it happily runs on a phone.

The whole experiment fits in one HTML file — copy it or download it:

Source code — flow-field.html(168 lines)Download .html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Flow field — canvas, ~1 KB of script</title>
<!--
  A few hundred particles drifting along a slowly-evolving vector field,
  leaving fading trails. Dependency-free canvas sketch.
  From slavkobabic.com/experiments — copy freely.

  The "field" is deliberately cheap — no Perlin noise, just a few layered
  sines of position and time. It only has to be smooth, so neighbouring
  particles agree on roughly where to go.
-->
<style>
  :root {
    color-scheme: dark;
    --bg: #0f0e17;
    --border: #312e40;
    --accent: #a78bfa;
  }

  @media (prefers-color-scheme: light) {
    :root {
      color-scheme: light;
      --bg: #fbfaff;
      --border: #dedbe8;
      --accent: #6d28d9;
    }
  }

  body {
    margin: 0;
    min-height: 100vh;
    display: grid;
    place-items: center;
    background: var(--bg);
  }

  .ff-wrap {
    width: min(92vw, 860px);
    border: 1px solid var(--border);
    border-radius: 10px;
    overflow: hidden;
  }

  canvas {
    display: block;
    width: 100%;
    height: clamp(240px, 42vw, 420px);
  }
</style>
</head>
<body>

<div class="ff-wrap">
  <canvas data-flow-field aria-hidden="true"></canvas>
</div>

<script>
  const canvas = document.querySelector('[data-flow-field]');
  const g = canvas.getContext('2d');

  const styles = getComputedStyle(document.documentElement);
  const accent = styles.getPropertyValue('--accent').trim();
  const bg = styles.getPropertyValue('--bg').trim();
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  let w = 0;
  let h = 0;
  let dpr = Math.min(window.devicePixelRatio || 1, 2);
  const COUNT = 900;
  const particles = [];

  // Cheap, seedless flow field: layered sines give a smooth angle per point.
  const angleAt = (x, y, t) =>
    Math.sin(x * 0.0022 + t) * 1.8 +
    Math.cos(y * 0.0025 - t * 0.6) * 1.8 +
    Math.sin((x + y) * 0.0016 + t * 0.3) * 1.2;

  function fit() {
    const rect = canvas.getBoundingClientRect();
    w = Math.max(1, Math.floor(rect.width));
    h = Math.max(1, Math.floor(rect.height));
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    g.setTransform(dpr, 0, 0, dpr, 0, 0);
    g.fillStyle = bg;
    g.fillRect(0, 0, w, h);
  }

  function seed() {
    particles.length = 0;
    for (let i = 0; i < COUNT; i++) {
      particles.push({ x: Math.random() * w, y: Math.random() * h });
    }
  }

  let raf = 0;
  let t = 0;

  function step() {
    // Fade the previous frame slightly to leave trails.
    g.globalAlpha = 0.06;
    g.fillStyle = bg;
    g.fillRect(0, 0, w, h);
    g.globalAlpha = 0.5;
    g.strokeStyle = accent;
    g.lineWidth = 1;

    g.beginPath();
    for (const p of particles) {
      const a = angleAt(p.x, p.y, t);
      const nx = p.x + Math.cos(a) * 1.1;
      const ny = p.y + Math.sin(a) * 1.1;
      g.moveTo(p.x, p.y);
      g.lineTo(nx, ny);
      p.x = nx;
      p.y = ny;
      // Respawn particles that wander off-canvas.
      if (p.x < 0 || p.x > w || p.y < 0 || p.y > h) {
        p.x = Math.random() * w;
        p.y = Math.random() * h;
      }
    }
    g.stroke();
    g.globalAlpha = 1;

    t += 0.0016;
    raf = requestAnimationFrame(step);
  }

  function staticFrame() {
    // Reduced motion: draw one calm frame of short field segments.
    g.globalAlpha = 0.5;
    g.strokeStyle = accent;
    g.lineWidth = 1;
    g.beginPath();
    for (let x = 12; x < w; x += 26) {
      for (let y = 12; y < h; y += 26) {
        const a = angleAt(x, y, 0);
        g.moveTo(x, y);
        g.lineTo(x + Math.cos(a) * 9, y + Math.sin(a) * 9);
      }
    }
    g.stroke();
    g.globalAlpha = 1;
  }

  window.addEventListener('resize', () => {
    fit();
    seed();
    if (reduce) staticFrame();
  });

  fit();
  seed();
  if (reduce) {
    staticFrame();
  } else {
    raf = requestAnimationFrame(step);
  }
</script>

</body>
</html>

Technologies

  • Canvas
  • JavaScript

Topics

  • Generative art