09. Vector field · 1996
Flow field
About
Each particle integrates a procedural vector field: sines for the ambient flow, plus a moving singularity that acts as a slow cyclone. The pointer adds a second vortex wherever it sits. Trails are a cheap canvas fade.
Move the pointer over the preview to twist the field into a vortex.
Browser APIs
- Canvas 2D
- Pointer Events
- requestAnimationFrame
- matchMedia
If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. prefers-reduced-motion freezes the first still.
Source
(function () {
const PARAMS = {
color: "#6ee7b7",
speed: 1
};
function hexToRgb(hex) {
const n = parseInt(String(hex).replace("#", ""), 16);
if (isNaN(n)) return [110, 231, 183];
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
function rgbCss(hex, a) {
const r = hexToRgb(hex);
return "rgba(" + r[0] + ", " + r[1] + ", " + r[2] + ", " + a + ")";
}
const canvas = document.getElementById("c");
const fallback = document.getElementById("fallback");
const ctx = canvas.getContext("2d");
if (!ctx) {
fallback.hidden = false;
fallback.textContent = "Canvas 2D is not available in this browser.";
return;
}
const COUNT = 1800;
const parts = new Array(COUNT);
function spawn(p, w, h) {
p.x = Math.random() * w;
p.y = Math.random() * h;
p.vx = 0;
p.vy = 0;
}
for (let i = 0; i < COUNT; i++) {
parts[i] = { x: 0, y: 0, vx: 0, vy: 0 };
}
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const pointer = { x: 0, y: 0, active: false };
let last = 0;
let raf = 0;
let seeded = false;
canvas.addEventListener("pointermove", function (e) {
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
pointer.x = (e.clientX - rect.left) * dpr;
pointer.y = (e.clientY - rect.top) * dpr;
pointer.active = true;
}, { passive: true });
canvas.addEventListener("pointerleave", function () { pointer.active = false; });
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
if (!seeded) {
for (let i = 0; i < COUNT; i++) spawn(parts[i], w, h);
seeded = true;
}
}
}
function field(x, y, t, w, h) {
const nx = x / w;
const ny = y / h;
let fx = Math.sin((ny * 7.2 + t * 0.35) * 1.1) + Math.cos((nx * 5.4 - t * 0.22) * 1.3);
let fy = Math.cos((nx * 6.1 - t * 0.31) * 1.05) + Math.sin((ny * 4.8 + t * 0.27) * 1.2);
const ax = w * (0.5 + 0.28 * Math.cos(t * 0.31));
const ay = h * (0.5 + 0.24 * Math.sin(t * 0.24));
const dx = x - ax;
const dy = y - ay;
const r2 = dx * dx + dy * dy + 1800;
fx += -dy * 420 / r2;
fy += dx * 420 / r2;
if (pointer.active) {
const pdx = x - pointer.x;
const pdy = y - pointer.y;
const pr2 = pdx * pdx + pdy * pdy + 900;
fx += -pdy * 1400 / pr2;
fy += pdx * 1400 / pr2;
}
return { fx, fy };
}
function frame(now) {
const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
last = now;
const t = now * 0.001;
resize();
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = reduced ? "#0b0c0e" : "rgba(11, 12, 14, 0.16)";
ctx.fillRect(0, 0, w, h);
ctx.lineWidth = 1.45;
ctx.lineCap = "round";
for (let i = 0; i < COUNT; i++) {
const p = parts[i];
const f = field(p.x, p.y, t, w, h);
p.vx = p.vx * 0.92 + f.fx * 28 * dt * PARAMS.speed;
p.vy = p.vy * 0.92 + f.fy * 28 * dt * PARAMS.speed;
const speed = Math.hypot(p.vx, p.vy);
const nx = p.x + p.vx;
const ny = p.y + p.vy;
const a = 0.28 + Math.min(0.72, speed * 0.04);
ctx.strokeStyle = rgbCss(PARAMS.color, a);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(nx, ny);
ctx.stroke();
p.x = nx;
p.y = ny;
if (p.x < -8 || p.y < -8 || p.x > w + 8 || p.y > h + 8) spawn(p, w, h);
}
if (!reduced) raf = requestAnimationFrame(frame);
}
if (reduced) {
resize();
frame(performance.now());
} else {
raf = requestAnimationFrame(frame);
}
})();