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>