Noise Flow Field
A canvas flow-field where thousands of particles drift along a value-noise vector field, painting organic fading streamlines you can retune live.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.stage {
min-height: 100%;
display: grid;
place-items: center;
padding: 24px;
}
.canvas-wrap {
position: relative;
width: min(960px, 100%);
aspect-ratio: 16 / 10;
border-radius: var(--r-lg);
overflow: hidden;
border: 1px solid var(--line);
box-shadow:
0 30px 80px -30px rgba(0, 0, 0, 0.8),
inset 0 0 0 1px rgba(255, 255, 255, 0.02);
background: #07080a;
}
#field {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
/* overlays */
.overlay {
position: absolute;
left: 0;
right: 0;
padding: 16px;
pointer-events: none;
z-index: 2;
}
.overlay--top {
top: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
background: linear-gradient(180deg, rgba(7, 8, 10, 0.72), rgba(7, 8, 10, 0));
}
.overlay--bottom {
bottom: 0;
display: flex;
justify-content: center;
background: linear-gradient(0deg, rgba(7, 8, 10, 0.82), rgba(7, 8, 10, 0));
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, var(--accent-2), var(--accent));
box-shadow: 0 0 16px rgba(139, 92, 246, 0.8);
flex: none;
}
.brand-txt h1 {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand-txt p {
margin: 2px 0 0;
font-size: 12px;
color: var(--muted);
}
.stats {
display: flex;
gap: 8px;
margin: 0;
}
.stat {
min-width: 66px;
padding: 6px 10px;
border-radius: var(--r-sm);
background: rgba(21, 22, 27, 0.7);
border: 1px solid var(--line);
backdrop-filter: blur(8px);
text-align: right;
}
.stat dt {
margin: 0;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.stat dd {
margin: 2px 0 0;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 16px;
font-weight: 500;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
/* control panel */
.panel {
pointer-events: auto;
width: min(760px, 100%);
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 18px;
align-items: end;
padding: 16px 18px;
border-radius: var(--r-md);
background: rgba(21, 22, 27, 0.78);
border: 1px solid var(--line);
backdrop-filter: blur(14px);
box-shadow: 0 12px 40px -12px rgba(0, 0, 0, 0.7);
}
.ctrl {
min-width: 0;
}
.ctrl > label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--muted);
margin-bottom: 8px;
}
.ctrl-row {
display: flex;
align-items: center;
gap: 12px;
}
output {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 13px;
font-weight: 500;
color: var(--accent-2);
min-width: 44px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* range slider */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 999px;
background: linear-gradient(
90deg,
var(--accent) 0%,
var(--accent) var(--fill, 40%),
var(--surface-2) var(--fill, 40%),
var(--surface-2) 100%
);
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--accent);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
transition: transform 0.12s ease;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--accent);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
}
input[type="range"]:hover::-webkit-slider-thumb {
transform: scale(1.15);
}
input[type="range"]:focus-visible {
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4);
}
.actions {
display: flex;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
font-family: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
transform 0.1s ease;
white-space: nowrap;
}
.btn:hover {
background: #262935;
border-color: rgba(255, 255, 255, 0.28);
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.45);
}
.btn--accent {
background: linear-gradient(180deg, #9b6cff, var(--accent));
border-color: transparent;
color: #fff;
}
.btn--accent:hover {
background: linear-gradient(180deg, #a67dff, #7c4ff0);
}
/* icons */
.icon {
width: 12px;
height: 12px;
flex: none;
display: inline-block;
background: currentColor;
}
/* pause bars (default, aria-pressed true = playing) */
.icon {
-webkit-mask: var(--m) center / contain no-repeat;
mask: var(--m) center / contain no-repeat;
--m: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect x='6' y='5' width='4' height='14' rx='1'/%3E%3Crect x='14' y='5' width='4' height='14' rx='1'/%3E%3C/svg%3E");
}
.btn[aria-pressed="false"] .icon {
--m: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7 4l13 8-13 8z'/%3E%3C/svg%3E");
}
.icon--reset {
--m: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 5V2L7 6l5 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z'/%3E%3C/svg%3E");
}
@media (max-width: 640px) {
.panel {
grid-template-columns: 1fr 1fr;
}
.actions {
grid-column: 1 / -1;
justify-content: stretch;
}
.btn {
flex: 1;
justify-content: center;
}
}
@media (max-width: 520px) {
.stage {
padding: 12px;
}
.canvas-wrap {
aspect-ratio: 3 / 4;
}
.brand-txt p {
display: none;
}
.panel {
grid-template-columns: 1fr;
gap: 14px;
}
.actions {
grid-column: auto;
}
}
@media (prefers-reduced-motion: reduce) {
input[type="range"]::-webkit-slider-thumb {
transition: none;
}
}/* Noise Flow Field — particles steered by a self-contained value-noise vector field.
Pure vanilla JS: Canvas 2D + requestAnimationFrame + devicePixelRatio scaling. */
(() => {
"use strict";
const canvas = document.getElementById("field");
const ctx = canvas.getContext("2d", { alpha: false });
const countEl = document.getElementById("count");
const scaleEl = document.getElementById("scale");
const countVal = document.getElementById("count-val");
const scaleVal = document.getElementById("scale-val");
const toggleBtn = document.getElementById("toggle");
const toggleLabel = document.getElementById("toggle-label");
const resetBtn = document.getElementById("reset");
const fpsEl = document.getElementById("fps");
const pcountEl = document.getElementById("pcount");
// ---- Self-contained 2D value noise (hashed integer lattice + smoothstep) ----
let seed = (Math.random() * 65536) | 0;
function hash(x, y) {
// integer hash -> [0,1)
let h = (x * 374761393 + y * 668265263 + seed * 2147483647) | 0;
h = (h ^ (h >>> 13)) * 1274126177;
h = h ^ (h >>> 16);
return (h >>> 0) / 4294967296;
}
const fade = (t) => t * t * (3 - 2 * t); // smoothstep
const lerp = (a, b, t) => a + (b - a) * t;
function valueNoise(x, y) {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const fx = fade(x - x0);
const fy = fade(y - y0);
const n00 = hash(x0, y0);
const n10 = hash(x0 + 1, y0);
const n01 = hash(x0, y0 + 1);
const n11 = hash(x0 + 1, y0 + 1);
return lerp(lerp(n00, n10, fx), lerp(n01, n11, fx), fy);
}
// fractal noise for richer, layered flow
function fbm(x, y) {
let sum = 0;
let amp = 0.5;
let freq = 1;
for (let o = 0; o < 3; o++) {
sum += valueNoise(x * freq, y * freq) * amp;
freq *= 2;
amp *= 0.5;
}
return sum; // ~[0,1)
}
// ---- State ----
let W = 0,
H = 0,
dpr = 1;
let particles = [];
let running = true;
let zStep = 0; // time evolution of the field
const HUE_BASE = 250; // violet family
function makeParticle() {
return {
x: Math.random() * W,
y: Math.random() * H,
px: 0,
py: 0,
life: 40 + Math.random() * 160,
};
}
function setCount(n) {
n = Math.round(n);
if (n < particles.length) {
particles.length = n;
} else {
while (particles.length < n) particles.push(makeParticle());
}
pcountEl.textContent = n.toLocaleString("en-US");
}
function resize() {
const rect = canvas.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = Math.max(1, Math.round(rect.width * dpr));
H = Math.max(1, Math.round(rect.height * dpr));
canvas.width = W;
canvas.height = H;
// paint background solid
ctx.fillStyle = "#07080a";
ctx.fillRect(0, 0, W, H);
// reposition any out-of-bounds particles
for (const p of particles) {
if (p.x > W || p.y > H) {
p.x = Math.random() * W;
p.y = Math.random() * H;
}
}
}
function fieldScale() {
// slider 1..20 -> noise cell size in pixels (bigger scale value = tighter turbulence)
return Number(scaleEl.value) / 2200;
}
function reset(reseed) {
if (reseed) {
seed = (Math.random() * 65536) | 0;
zStep = 0;
}
ctx.fillStyle = "#07080a";
ctx.fillRect(0, 0, W, H);
particles = [];
setCount(Number(countEl.value));
}
// ---- Animation loop ----
let last = performance.now();
let fpsAccum = 0;
let fpsFrames = 0;
let fpsTimer = 0;
function step(now) {
const dt = Math.min(now - last, 40);
last = now;
// FPS meter (updated ~4x/sec)
fpsFrames++;
fpsAccum += dt;
fpsTimer += dt;
if (fpsTimer >= 250) {
const fps = Math.round(1000 / (fpsAccum / fpsFrames));
fpsEl.textContent = String(fps);
fpsAccum = 0;
fpsFrames = 0;
fpsTimer = 0;
}
if (running) {
// fade previous frame -> trails
ctx.fillStyle = "rgba(7, 8, 10, 0.055)";
ctx.fillRect(0, 0, W, H);
const s = fieldScale();
const speed = 1.4 * dpr;
zStep += 0.0009;
ctx.lineWidth = 1 * dpr;
ctx.lineCap = "round";
for (const p of particles) {
const angle = fbm(p.x * s, p.y * s + zStep) * Math.PI * 4;
p.px = p.x;
p.py = p.y;
p.x += Math.cos(angle) * speed;
p.y += Math.sin(angle) * speed;
p.life--;
// color drifts with the field direction
const hue = HUE_BASE + Math.cos(angle) * 55 - 30;
ctx.strokeStyle = `hsla(${hue}, 85%, 62%, 0.5)`;
ctx.beginPath();
ctx.moveTo(p.px, p.py);
ctx.lineTo(p.x, p.y);
ctx.stroke();
// respawn when off-screen or aged out
if (p.x < 0 || p.x > W || p.y < 0 || p.y > H || p.life <= 0) {
p.x = Math.random() * W;
p.y = Math.random() * H;
p.life = 40 + Math.random() * 160;
}
}
}
requestAnimationFrame(step);
}
// ---- Wiring controls ----
function paintSlider(el) {
const min = Number(el.min);
const max = Number(el.max);
const pct = ((Number(el.value) - min) / (max - min)) * 100;
el.style.setProperty("--fill", pct + "%");
}
countEl.addEventListener("input", () => {
countVal.textContent = Number(countEl.value).toLocaleString("en-US");
paintSlider(countEl);
setCount(Number(countEl.value));
});
scaleEl.addEventListener("input", () => {
scaleVal.textContent = scaleEl.value;
paintSlider(scaleEl);
});
toggleBtn.addEventListener("click", () => {
running = !running;
toggleBtn.setAttribute("aria-pressed", String(running));
toggleLabel.textContent = running ? "Pause" : "Play";
});
resetBtn.addEventListener("click", () => reset(true));
let resizeRAF = 0;
window.addEventListener("resize", () => {
cancelAnimationFrame(resizeRAF);
resizeRAF = requestAnimationFrame(resize);
});
// ---- Init ----
countVal.textContent = Number(countEl.value).toLocaleString("en-US");
scaleVal.textContent = scaleEl.value;
paintSlider(countEl);
paintSlider(scaleEl);
resize();
setCount(Number(countEl.value));
requestAnimationFrame((t) => {
last = t;
requestAnimationFrame(step);
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Noise Flow Field</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage">
<section class="canvas-wrap" aria-label="Animated noise flow field">
<canvas id="field" role="img"
aria-label="Generative flow field of particles following a value-noise vector field"></canvas>
<header class="overlay overlay--top">
<div class="brand">
<span class="dot" aria-hidden="true"></span>
<div class="brand-txt">
<h1>Noise Flow Field</h1>
<p>Particles steered by a value-noise vector field</p>
</div>
</div>
<dl class="stats" aria-live="polite">
<div class="stat">
<dt>FPS</dt>
<dd id="fps">--</dd>
</div>
<div class="stat">
<dt>Particles</dt>
<dd id="pcount">--</dd>
</div>
</dl>
</header>
<div class="overlay overlay--bottom">
<div class="panel" role="group" aria-label="Flow field controls">
<div class="ctrl">
<label for="count">Particle count</label>
<div class="ctrl-row">
<input id="count" type="range" min="200" max="4000" step="100" value="1400" />
<output id="count-val" for="count">1400</output>
</div>
</div>
<div class="ctrl">
<label for="scale">Field scale</label>
<div class="ctrl-row">
<input id="scale" type="range" min="1" max="20" step="1" value="7" />
<output id="scale-val" for="scale">7</output>
</div>
</div>
<div class="actions">
<button id="toggle" type="button" class="btn btn--accent" aria-pressed="true">
<span class="icon" id="toggle-icon" aria-hidden="true"></span>
<span id="toggle-label">Pause</span>
</button>
<button id="reset" type="button" class="btn">
<span class="icon icon--reset" aria-hidden="true"></span>
Reset
</button>
</div>
</div>
</div>
</section>
</main>
<script src="script.js"></script>
</body>
</html>The Noise Flow Field renders a living generative artwork on a single <canvas>. A grid of angles is derived from a self-contained value-noise function, and hundreds to thousands of particles read that field frame by frame, steering toward the local flow direction. Instead of clearing every frame, the canvas is painted with a low-opacity dark rectangle so older positions decay slowly, leaving silky streamlines that reveal the shape of the hidden vector field.
Three controls let you shape the piece in real time: a particle-count slider trades density for performance, a field-scale slider zooms the noise between tight turbulence and broad sweeping currents, and a reset button reseeds the noise and scatters fresh particles. A play/pause toggle and a live FPS / particle readout round out the interaction, and the whole thing is keyboard-operable with visible focus rings.
Under the hood this demonstrates the Canvas 2D API together with requestAnimationFrame for the animation loop and devicePixelRatio scaling for crisp rendering on high-density displays. The value-noise implementation — a hashed integer lattice with smooth interpolation — needs no external library, so the entire effect is pure vanilla JavaScript.