SVGGratuit

Perlin Flow Field

Des centaines de particules bleu-violet suivent les courants d'un vrai bruit de Perlin 3D qui se transforme lentement, et laissent des traînées soyeuses sur un canvas net en Retina.

CanvasPerlinParticles

Mis à jour le

Survolez ou cliquez la scène pour interagir

Cet effet est gratuit — la catégorie SVG en contient 30 au total, dont 3 gratuits. Effect.Labs regroupe 840 effets vanilla. Découvrir la catégorie →

Exemples d'usage

Studio d'enregistrement · Lyon

Là où le son
prend forme.

Deux cabines traitées, une console analogique 32 pistes et un ingénieur du son à chaque session.

Session 4 h · 160 € Journée · 290 €

① Fond vivant du hero d'un studio d'enregistrement

QuandSur la page d'accueil du studio Brumaval, le champ de flux occupe tout le hero ; à gauche, un voile sombre porte le titre, les deux tarifs et le bouton « Réserver une session ».
PourquoiDes courants lents qui se plient évoquent la matière sonore sans rien illustrer de littéral, et le mouvement reste assez discret pour ne pas concurrencer l'appel à l'action.
RéglagesRacine en position:absolute; inset:0 avec --pff-height:100% ; canvas en aria-hidden="true" (décor) ; voile linear-gradient(90deg, …) de 92 % à 10 % d'opacité posé entre le canvas et le texte. Réglages du script inchangés.

Bibliothèque

Playlists · Albums · Artistes
Concentration
Playlist à la une

Concentration

42 titres · 2 h 48 min

Nappes lentes et piano feutré pour travailler sans paroles.

Footing31 titres
Pluie lente18 titres
Nocturne25 titres

② Pochette animée de la playlist mise en avant

QuandDans la bibliothèque musicale Orfélune, la playlist « Concentration » à la une a une pochette de 148 px qui bouge ; les autres playlists gardent une vignette fixe.
PourquoiÀ cette taille, une seule pochette vivante suffit à attirer l'œil vers la sélection du moment, et les courants calmes collent au thème « travailler sans paroles ».
RéglagesRacine dans un carré de 148 × 148 px (--pff-height:100%) : la densité étant fixée par surface, le script y sème le minimum prévu de 60 particules. Titre de la pochette posé en surimpression avec une ombre portée.

File d'attente

Courants lents4:12
Marée basse3:47
Verre de sel5:03
Longue grève4:31
Heure grise3:58
En lecture

Courants lents

Tessaline

1:364:12

③ Fond de lecteur qui ne bouge que pendant la lecture

QuandDans la carte « En lecture », le champ de flux remplit le fond ; le bouton Lecture/Pause affiche ou masque la racine, et la file d'attente reste à côté.
PourquoiLe mouvement devient un témoin d'état : il y a de la musique quand ça coule, l'aplat revient quand on met en pause. Masquer la racine arrête réellement le calcul.
RéglagesLe script de la scène bascule l'attribut hidden de .fx-0609-root ; le code vendu détecte la taille nulle, s'arrête, puis reprend au même endroit quand la racine réapparaît. Dégradé vers le noir en bas de carte pour les commandes.

Comment ça marche

La racine .fx-0609-root contient un seul <canvas class="pff-canvas"> de 300 px de haut (variable --pff-height) et de toute la largeur. Le script lit la taille affichée du canvas, la multiplie par devicePixelRatio pour sa résolution interne, puis la relit à chaque changement grâce à un ResizeObserver. Tant que cette taille vaut 0 (onglet, accordéon ou modale fermés au chargement), rien ne tourne ; l'animation part dès que la zone apparaît. Chaque racine de la page a sa propre instance : deux copies tournent indépendamment, et le script marque chaque racine lancée (data-pff-ready) pour ne jamais la démarrer deux fois, même s'il est collé deux fois dans la page.

Le cœur est la fonction createNoise() : un bruit de Perlin « amélioré » (la version publiée par Ken Perlin en 2002), avec une table de permutation de 256 valeurs mélangée à chaque chargement, des gradients aux arêtes d'un cube et la courbe de lissage 6t⁵ − 15t⁴ + 10t³. On l'interroge en trois dimensions : x × SCALE, y × SCALE et le temps, qui avance de DRIFT par milliseconde. La valeur obtenue, multipliée par 4π, donne l'angle de déplacement de la particule à cet endroit : deux points voisins reçoivent des angles voisins, d'où ces courants continus qui se plient peu à peu.

Une particule par 480 px² (500 sur une zone de 800 × 300) avance de 0,5 à 1,5 px par image à 60 i/s ; le pas est recalculé à partir du temps écoulé (plafonné à 50 ms), donc la vitesse est la même sur un écran à 60 ou à 120 Hz. Au bout de 2 à 6 s, chaque particule renaît à un endroit tiré au hasard : sans ce renouvellement, toutes finiraient regroupées dans quelques courants. À chaque image, 5 % de l'image précédente est effacée vers la transparence, ce qui produit les traînées ; le fond visible est donc celui de la racine. Un IntersectionObserver coupe la boucle quand le canvas sort de l'écran.

Accessibilité

  • Libellé : le canvas porte role="img" et un aria-label qui décrit l'image. Utilisé en fond décoratif derrière un titre, remplacez-les par aria-hidden="true" pour ne pas faire lire une description inutile.
  • Mouvement réduit : avec prefers-reduced-motion: reduce, le champ est figé et les particules tracent leurs lignes de courant d'un seul coup ; on obtient une image fixe et complète, jamais une zone vide. Le réglage est relu s'il change pendant la visite.
  • Pause : l'effet n'a pas de bouton pause intégré. Pour une animation qui dure plus de 5 s à côté d'un contenu (critère WCAG 2.2.2), ajoutez un bouton qui bascule l'attribut hidden de la racine : le code s'arrête dès que la taille tombe à 0 et repart là où il en était.
  • Clavier : rien n'est interactif dans l'effet, donc rien à atteindre au clavier et aucun piège de focus.
  • Lisibilité : les traînées sont lumineuses sur fond sombre. Un texte posé dessus a besoin d'un voile en dégradé (voir l'exemple du studio) pour garder un contraste suffisant.

Compatibilité navigateur

Canvas 2D et JavaScript sans dépendance (environ 5,8 Ko non minifié). ResizeObserver : Chrome 64, Firefox 69, Safari 13.1 ; IntersectionObserver : Chrome 51, Firefox 55, Safari 12.1 (données MDN). Mesuré dans Chromium 151, Firefox 153 et WebKit 26.5 : rendu net à densité 2, deux copies indépendantes, démarrage après un affichage différé, image fixe en mouvement réduit, aucune image calculée hors écran, aucune erreur.

Chrome 64+✓ Complet (mesuré)
Firefox 69+✓ Complet (mesuré)
Safari 13.1+✓ Complet (mesuré dans WebKit)
Edge 79+✓ Complet (moteur Chromium)
Safari iOS 13.4+✓ Complet (WebKit, non vérifié sur appareil)
Chrome Android 64+✓ Complet (moteur Chromium)

Sans ResizeObserver, le canvas suit seulement le redimensionnement de la fenêtre ; sans IntersectionObserver, l'animation continue hors écran. Avant Safari 14, un changement du réglage « réduire les animations » n'est pris en compte qu'au rechargement. Sans JavaScript, la zone reste un aplat sombre.

Le code

Copiez les trois blocs dans votre page. Aucune dépendance.

HTML
<div class="fx-0609-root">
  <canvas class="pff-canvas" role="img" aria-label="Particules bleues et violettes qui suivent les courants d'un champ de bruit de Perlin"></canvas>
</div>
CSS
.fx-0609-root,
.fx-0609-root * {
  box-sizing: border-box;
}

.fx-0609-root {
  position: relative;
  width: 100%;
  overflow: hidden;
  background: #0a0a0f;
}

/* Hauteur fixe par défaut ; --pff-height accepte aussi 100 % dans un parent de hauteur connue */
.fx-0609-root .pff-canvas {
  display: block;
  width: 100%;
  height: var(--pff-height, 300px);
}
JavaScript (fx-0609)
(function () {
  // Perlin Flow Field : chaque particule suit la direction donnée par un bruit de Perlin 3D (x, y, temps).
  var SCALE = 0.0035;        // taille des courants : plus petit = courants plus larges
  var DRIFT = 0.00009;       // vitesse à laquelle le champ se transforme (unités de bruit par ms)
  var DENSITY = 480;         // une particule pour 480 px² de zone
  var TRAIL = 0.05;          // part de l'image effacée à chaque image (60 i/s) : plus petit = traînées plus longues
  var LIFE = 4000;           // durée de vie moyenne d'une particule (ms) avant de renaître ailleurs
  var HUE = 220;             // teinte de départ (bleu) ; les particules vont de HUE à HUE + 60

  var reduced = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;

  // Bruit de Perlin « amélioré » (Ken Perlin, 2002) : gradients sur une grille 3D, interpolation lissée.
  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) {
    // Une seule instance par racine, même si le script est collé plusieurs fois dans la 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;

    // Une particule renaît à un endroit au hasard : sans cela, toutes finissent dans les mêmes courants.
    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;
      // Les traînées s'effacent vers la transparence : le fond visible est celui de la racine (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();
      }
    }

    // Mouvement réduit : le champ est figé et les particules tracent leurs lignes de courant une fois.
    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);
    }

    // Taille lue sur la zone affichée ; rien ne démarre tant qu'elle vaut 0 (onglet ou accordéon fermé).
    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);
})();

Personnaliser

Options passées à l'API ou attributs data-* :

Option / propriétéDéfautEffet
Taille des courants (JS — SCALE) 0.0035 Plus petit (0.002) : de larges courants paresseux. Plus grand (0.008) : des tourbillons serrés et nerveux.
Transformation du champ (JS — DRIFT) 0.00009 par ms 0 fige le champ (les particules suivent toujours les mêmes lignes) ; 0.0003 le fait changer sous les yeux.
Densité (JS — DENSITY) 480 px² par particule 240 double le nombre de particules, 1000 l'allège. Le total reste borné entre 60 et 3 000.
Longueur des traînées (JS — TRAIL) 0.05 Part effacée à chaque image : 0.02 donne de longues traînées laiteuses, 0.15 des points presque nets.
Durée de vie (JS — LIFE) 4000 ms en moyenne (de 2 à 6 s) Une valeur longue laisse les particules se regrouper en quelques grands courants ; une valeur courte répartit le dessin sur toute la zone.
Couleurs (JS — HUE) 220 (bleu → violet sur 60°) 160 donne du vert d'eau au bleu, 330 du rose à l'orange. La saturation (80 %) et la luminosité (60 %) sont dans la même ligne du script.
Fond (CSS — background de .fx-0609-root) #0a0a0f Les traînées s'effacent vers la transparence : seule cette couleur est à changer. Sur fond clair, préférez des particules plus sombres (luminosité 40 % dans la ligne de couleur).
Hauteur (CSS — --pff-height) 300px Toute hauteur fixe ; 100% pour remplir un parent de hauteur connue. Le canvas se redimensionne seul.

FAQ

Est-ce un vrai bruit de Perlin ?

Oui. createNoise() implémente l'algorithme de Perlin de 2002 (table de permutation, gradients pseudo-aléatoires sur une grille, interpolation lissée), en 3D pour que le temps fasse évoluer le champ sans saut. La table est mélangée par Math.random() : chaque chargement et chaque copie dessinent des courants différents.

Pourquoi les particules finissent-elles par dessiner quelques grandes lignes ?

Un champ de Perlin a des zones où les directions convergent : les particules s'y rassemblent naturellement. Le renouvellement après LIFE millisecondes les redistribue. Montez LIFE à 10000 pour des lignes plus marquées, descendez à 1500 pour un voile régulier.

Est-ce que ça pèse sur les performances ?

Une zone de 800 × 300 calcule 500 particules par image, soit 500 appels au bruit et 500 petits disques : c'est léger sur un ordinateur comme sur un téléphone récent. Le calcul s'arrête hors écran et dans un onglet en arrière-plan. Pour un hero plein écran, gardez DENSITY à 480 ou plus : le plafond de 3 000 particules évite les surprises.