Where sound
takes shape.
Two treated booths, a 32-track analog desk and a sound engineer for every session.
Hundreds of blue-violet particles follow the currents of real 3D Perlin noise that slowly reshapes itself, leaving silky trails on a crisp Retina canvas.
Updated
This effect is free — the SVG category contains 30 effects total, including 3 free. Effect.Labs has 840 vanilla effects. Explore the category →
Two treated booths, a 32-track analog desk and a sound engineer for every session.
Slow pads and muted piano to work without lyrics.
Tessaline
The .fx-0609-root wrapper holds a single <canvas class="pff-canvas">, 300 px tall (the --pff-height variable) and full width. The script reads the canvas's displayed size, multiplies it by devicePixelRatio for the internal resolution, and reads it again on every change through a ResizeObserver. While that size is 0 (a tab, accordion or modal closed at load), nothing runs; the animation starts as soon as the area shows up. Every root on the page gets its own instance, so two copies run independently; the script marks each root it starts (data-pff-ready) so it never starts it twice, even when pasted twice on the page.
The heart of it is createNoise(): "improved" Perlin noise (the version Ken Perlin published in 2002), with a 256-entry permutation table shuffled on each load, gradients pointing to the edges of a cube and the 6t⁵ − 15t⁴ + 10t³ fade curve. It is sampled in three dimensions: x × SCALE, y × SCALE and time, which moves forward by DRIFT per millisecond. The result, multiplied by 4π, is the heading of the particle at that spot: neighbouring points get neighbouring angles, which is why the currents look continuous and bend little by little.
One particle per 480 px² (500 on an 800 × 300 area) moves 0.5 to 1.5 px per frame at 60 fps; the step is derived from elapsed time (capped at 50 ms), so speed is the same on a 60 Hz or a 120 Hz screen. After 2 to 6 s, each particle is reborn at a random spot: without that renewal they would all end up bunched in a few currents. On every frame 5 % of the previous image is erased towards transparency, which creates the trails; the visible background is therefore the root's. An IntersectionObserver stops the loop when the canvas leaves the screen.
role="img" and an aria-label describing the picture. When it is a decorative background behind a heading, swap them for aria-hidden="true" so screen readers skip a pointless description.prefers-reduced-motion: reduce, the field is frozen and the particles trace their streamlines in one go, giving a complete still picture, never an empty box. The setting is re-read if it changes during the visit.hidden attribute: the code stops as soon as its size drops to 0 and resumes where it left off.2D canvas and dependency-free JavaScript (about 5.8 KB unminified). ResizeObserver: Chrome 64, Firefox 69, Safari 13.1; IntersectionObserver: Chrome 51, Firefox 55, Safari 12.1 (MDN data). Tested in Chromium 151, Firefox 153 and WebKit 26.5: sharp at 2× density, two independent copies, start after a delayed reveal, still image under reduced motion, no frames computed off screen, no errors.
Without ResizeObserver, the canvas only follows window resizes; without IntersectionObserver, the animation keeps running off screen. Before Safari 14, switching the reduce-motion setting only applies after a reload. Without JavaScript, the area stays a flat dark block.
Copy the three blocks into your page. No dependencies.
<div class="fx-0609-root">
<canvas class="pff-canvas" role="img" aria-label="Blue and violet particles following the currents of a Perlin noise field"></canvas>
</div>
.fx-0609-root,
.fx-0609-root * {
box-sizing: border-box;
}
.fx-0609-root {
position: relative;
width: 100%;
overflow: hidden;
background: #0a0a0f;
}
/* Fixed height by default; --pff-height also accepts 100% inside a parent of known height */
.fx-0609-root .pff-canvas {
display: block;
width: 100%;
height: var(--pff-height, 300px);
}
(function () {
// Perlin Flow Field: each particle follows the direction given by 3D Perlin noise (x, y, time).
var SCALE = 0.0035; // size of the currents: smaller = wider currents
var DRIFT = 0.00009; // how fast the field changes (noise units per ms)
var DENSITY = 480; // one particle per 480 px² of area
var TRAIL = 0.05; // share of the picture erased on each frame (60 fps): smaller = longer trails
var LIFE = 4000; // average particle lifetime (ms) before it is reborn elsewhere
var HUE = 220; // starting hue (blue); particles range from HUE to HUE + 60
var reduced = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
// "Improved" Perlin noise (Ken Perlin, 2002): gradients on a 3D grid, smoothed interpolation.
function createNoise() {
var p = new Uint8Array(512), i, j, t;
for (i = 0; i < 256; i++) p[i] = i;
for (i = 255; i > 0; i--) { j = Math.floor(Math.random() * (i + 1)); t = p[i]; p[i] = p[j]; p[j] = t; }
for (i = 0; i < 256; i++) p[i + 256] = p[i];
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function mix(a, b, t) { return a + t * (b - a); }
function grad(h, x, y, z) {
h &= 15;
var u = h < 8 ? x : y;
var v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}
return function (x, y, z) {
var X = Math.floor(x), Y = Math.floor(y), Z = Math.floor(z);
x -= X; y -= Y; z -= Z;
X &= 255; Y &= 255; Z &= 255;
var u = fade(x), v = fade(y), w = fade(z);
var A = p[X] + Y, AA = p[A] + Z, AB = p[A + 1] + Z;
var B = p[X + 1] + Y, BA = p[B] + Z, BB = p[B + 1] + Z;
return mix(
mix(mix(grad(p[AA], x, y, z), grad(p[BA], x - 1, y, z), u),
mix(grad(p[AB], x, y - 1, z), grad(p[BB], x - 1, y - 1, z), u), v),
mix(mix(grad(p[AA + 1], x, y, z - 1), grad(p[BA + 1], x - 1, y, z - 1), u),
mix(grad(p[AB + 1], x, y - 1, z - 1), grad(p[BB + 1], x - 1, y - 1, z - 1), u), v),
w);
};
}
function init(root) {
// One instance per root, even if the script is pasted several times on the page.
if (root.hasAttribute('data-pff-ready')) return;
root.setAttribute('data-pff-ready', '');
var canvas = root.querySelector('.pff-canvas');
var ctx = canvas && canvas.getContext ? canvas.getContext('2d') : null;
if (!ctx) return;
var noise = createNoise();
var W = 0, H = 0, ratio = 0, time = Math.random() * 100;
var particles = [], raf = 0, last = 0, visible = true;
// A particle is reborn at a random spot: without this, they all end up in the same currents.
function place(q) {
q.x = Math.random() * W;
q.y = Math.random() * H;
q.life = LIFE * (0.5 + Math.random());
}
function seed() {
var n = Math.max(60, Math.min(3000, Math.round(W * H / DENSITY)));
particles = [];
for (var i = 0; i < n; i++) {
var q = { v: 0.5 + Math.random(), c: 'hsla(' + Math.round(HUE + Math.random() * 60) + ', 80%, 60%, 0.8)' };
place(q);
q.life *= Math.random();
particles.push(q);
}
}
function advance(dt) {
var k = dt / 16.667;
// Trails fade to transparency: the visible background is the root's (CSS).
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0, 0, 0, ' + (1 - Math.pow(1 - TRAIL, k)) + ')';
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'source-over';
for (var i = 0; i < particles.length; i++) {
var q = particles[i];
var angle = noise(q.x * SCALE, q.y * SCALE, time) * Math.PI * 4;
q.x += Math.cos(angle) * q.v * k;
q.y += Math.sin(angle) * q.v * k;
if (q.x < 0) q.x += W; else if (q.x > W) q.x -= W;
if (q.y < 0) q.y += H; else if (q.y > H) q.y -= H;
q.life -= dt;
if (q.life <= 0) place(q);
ctx.fillStyle = q.c;
ctx.beginPath();
ctx.arc(q.x, q.y, 1, 0, Math.PI * 2);
ctx.fill();
}
}
// Reduced motion: the field is frozen and the particles trace their streamlines once.
function drawStill() {
for (var i = 0; i < 160; i++) advance(16.667);
}
function loop(t) {
var dt = last ? Math.min(t - last, 50) : 16.667;
last = t;
time += DRIFT * dt;
advance(dt);
raf = requestAnimationFrame(loop);
}
function stop() {
if (raf) cancelAnimationFrame(raf);
raf = 0;
}
function play() {
if (raf || !W || !visible || (reduced && reduced.matches)) return;
last = 0;
raf = requestAnimationFrame(loop);
}
// Size read from the displayed area; nothing starts while it is 0 (closed tab or accordion).
function resize(force) {
var w = canvas.clientWidth, h = canvas.clientHeight;
if (!w || !h) { stop(); return; }
var r = window.devicePixelRatio || 1;
if (force || w !== W || h !== H || r !== ratio) {
W = w; H = h; ratio = r;
canvas.width = Math.round(w * r);
canvas.height = Math.round(h * r);
ctx.setTransform(r, 0, 0, r, 0, 0);
seed();
if (reduced && reduced.matches) { stop(); drawStill(); }
}
play();
}
if (window.ResizeObserver) new ResizeObserver(function () { resize(false); }).observe(canvas);
else window.addEventListener('resize', function () { resize(false); });
if (window.IntersectionObserver) {
new IntersectionObserver(function (entries) {
visible = entries[entries.length - 1].isIntersecting;
if (visible) play(); else stop();
}).observe(canvas);
}
if (reduced && reduced.addEventListener) reduced.addEventListener('change', function () { stop(); resize(true); });
resize(false);
}
document.querySelectorAll('.fx-0609-root').forEach(init);
})();
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Current size (JS — SCALE) |
0.0035 | Smaller (0.002): wide lazy currents. Larger (0.008): tight, nervous swirls. |
Field change (JS — DRIFT) |
0.00009 per ms | 0 freezes the field (particles keep following the same lines); 0.0003 makes it shift before your eyes. |
Density (JS — DENSITY) |
480 px² per particle | 240 doubles the particle count, 1000 thins it out. The total stays between 60 and 3,000. |
Trail length (JS — TRAIL) |
0.05 | Share erased on each frame: 0.02 gives long milky trails, 0.15 nearly crisp dots. |
Lifetime (JS — LIFE) |
4000 ms on average (2 to 6 s) | A long value lets particles gather into a few big currents; a short one spreads the drawing over the whole area. |
Colours (JS — HUE) |
220 (blue → violet over 60°) | 160 runs from aqua to blue, 330 from pink to orange. Saturation (80 %) and lightness (60 %) live on the same script line. |
Background (CSS — background of .fx-0609-root) |
#0a0a0f | Trails fade to transparency, so this is the only colour to change. On a light background, use darker particles (40 % lightness in the colour line). |
Height (CSS — --pff-height) |
300px | Any fixed height; 100% to fill a parent of known height. The canvas resizes itself. |
Yes. createNoise() implements Perlin's 2002 algorithm (permutation table, pseudo-random gradients on a grid, smoothed interpolation), in 3D so that time evolves the field without jumps. The table is shuffled with Math.random(): every load and every copy draws different currents.
A Perlin field has zones where headings converge, and particles naturally gather there. Renewal after LIFE milliseconds redistributes them. Raise LIFE to 10000 for bolder lines, lower it to 1500 for an even veil.
An 800 × 300 area computes 500 particles per frame, i.e. 500 noise calls and 500 small discs: light on a laptop as well as on a recent phone. Computation stops off screen and in a background tab. For a full-screen hero, keep DENSITY at 480 or above; the 3,000-particle cap avoids surprises.