15. mart 2026.
Polje toka na canvasu
Nekoliko stotina čestica koje klize kroz vektorsko polje koje se polako menja, ostavljajući tragove koji blede — mala canvas skica bez ijedne biblioteke.
Animacije su ilustrativne — urađene za potrebe prezentacije.
Volim polja toka zato što je ideja gotovo trivijalna, a rezultat deluje živo. Svakoj tački u ravni dodeliš smer, rasporediš nekoliko stotina čestica i pustiš ih da prate polje ispod sebe. Ako svaki prethodni frejm malo izbledi, putanje ostavljaju tragove i cela slika počne da diše.
Polje je ovde namerno jednostavno — nema Perlinovog šuma, samo nekoliko naslaganih sinusa pozicije i vremena. Ne mora da bude fizički tačno. Dovoljno je da bude glatko, kako bi susedne čestice prirodno krenule u istom smeru i kako bi tok izgledao povezano, a ne nasumično. Spori vremenski član polako menja polje, pa se slika nikada potpuno ne umiri.
Dva detalja čine da više liči na gotovu komponentu nego na demo. Boju akcenta
čita direktno iz CSS-a, pa se automatski prilagođava svetloj i tamnoj temi,
bez ikakvog znanja o paleti. A kada je uključen
prefers-reduced-motion, animaciju preskače u potpunosti i iscrta jedan
statičan kadar polja — kratke poteze koji pokazuju smer trenutnog toka — pa
ideja ostaje jasna i bez pokreta. Sve zajedno staje u otprilike kilobajt
JavaScripta, bez ijedne biblioteke, i bez problema radi i na telefonu.
Ceo eksperiment staje u jedan HTML fajl — kopiraj ga ili preuzmi:
Izvorni kod — flow-field.html(168 linija)Preuzmi .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>