SVGFree

Perlin Flow Field

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.

CanvasPerlinParticles

Updated

Hover or click the scene to interact

This effect is free — the SVG category contains 30 effects total, including 3 free. Effect.Labs has 840 vanilla effects. Explore the category →

Usage examples

Recording studio · Lyon

Where sound
takes shape.

Two treated booths, a 32-track analog desk and a sound engineer for every session.

4-hour session · €160 Full day · €290

① Living hero background for a recording studio

WhenOn the Brumaval studio home page, the flow field fills the whole hero; on the left, a dark veil carries the heading, two rates and the "Book a session" button.
WhySlow bending currents suggest sound as a material without illustrating anything literally, and the motion stays quiet enough not to compete with the call to action.
SettingsRoot set to position:absolute; inset:0 with --pff-height:100%; canvas marked aria-hidden="true" (decoration); a linear-gradient(90deg, …) veil from 92 % to 10 % opacity sits between canvas and text. Script settings unchanged.

Library

Playlists · Albums · Artists
Focus
Featured playlist

Focus

42 tracks · 2 h 48 min

Slow pads and muted piano to work without lyrics.

Jogging31 tracks
Slow rain18 tracks
Nocturne25 tracks

② Animated cover for the featured playlist

WhenIn the Orfélune music library, the featured "Focus" playlist has a 148 px cover that moves; the other playlists keep a still thumbnail.
WhyAt that size a single living cover is enough to pull the eye towards the current pick, and the calm currents match the "work without lyrics" theme.
SettingsRoot inside a 148 × 148 px square (--pff-height:100%): since density is set per area, the script seeds the built-in minimum of 60 particles. The cover title sits on top with a drop shadow.

Up next

Slow currents4:12
Low tide3:47
Salt glass5:03
Long shore4:31
Grey hour3:58
Now playing

Slow currents

Tessaline

1:364:12

③ Player background that only moves while playing

WhenIn the "Now playing" card, the flow field fills the background; the Play/Pause button shows or hides the root, with the queue listed beside it.
WhyMotion becomes a status cue: music is on when it flows, the flat colour returns on pause. Hiding the root genuinely stops the computation.
SettingsThe scene script toggles the hidden attribute of .fx-0609-root; the sold code detects the zero size, stops, and resumes at the same point when the root reappears. A fade to black at the bottom of the card keeps the controls readable.

How it works

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.

Accessibility

  • Label: the canvas has 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.
  • Reduced motion: with 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.
  • Pausing: there is no built-in pause button. For an animation lasting over 5 s next to other content (WCAG 2.2.2), add a button that toggles the root's hidden attribute: the code stops as soon as its size drops to 0 and resumes where it left off.
  • Keyboard: nothing in the effect is interactive, so there is nothing to tab to and no focus trap.
  • Legibility: the trails are bright on a dark background. Text placed on top needs a gradient veil (see the studio example) to keep enough contrast.

Browser compatibility

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.

Chrome 64+✓ Full (measured)
Firefox 69+✓ Full (measured)
Safari 13.1+✓ Full (measured in WebKit)
Edge 79+✓ Full (Chromium engine)
Safari iOS 13.4+✓ Full (WebKit, not checked on a device)
Chrome Android 64+✓ Full (Chromium engine)

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.

The code

Copy the three blocks into your page. No dependencies.

HTML
<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>
CSS
.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);
}
JavaScript (fx-0609)
(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);
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
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.

FAQ

Is this real Perlin noise?

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.

Why do the particles end up drawing a few big lines?

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.

Is it heavy on performance?

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.