LiquidGratuit

Metaballs

Six boules indigo qui rebondissent dans tout le cadre et fusionnent avant même de se toucher : le contour est calculé pixel par pixel sur un canvas 2D, là où la somme de leurs champs dépasse 1, et le cœur s'éclaircit où les champs s'additionnent — 5 Ko de JavaScript, net sur écran Retina, en pause hors écran, image fixe si l'utilisateur réduit les animations.

CanvasAnimationOrganic

Mis à jour le

Survolez ou cliquez la scène pour interagir

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

Exemples d'usage

Saint-Aubin-les-Essarts DémarchesAgendaConseil

1er janvier 2027

Saint-Aubin et Les Essarts deviennent une seule commune

Une mairie, un conseil, deux bourgs. Ce qui change pour vos démarches →

① Annonce de la commune nouvelle — page d'accueil du site de deux villages qui fusionnent

QuandAu 1er janvier, le bandeau du site municipal annonce « Saint-Aubin et Les Essarts deviennent une seule commune » ; derrière le titre, trois boules dorées se rapprochent, s'étirent l'une vers l'autre et fusionnent, puis repartent.
PourquoiLe pont de matière qui naît avant le contact montre un rapprochement progressif, pas une collision : c'est exactement le récit d'une fusion de communes. Le contour reste net, le bandeau garde un air institutionnel.
Réglagesdata-balls="3" et data-color="214,164,44" (l'or commun aux deux blasons) sur la racine ; bloc étendu à tout le bandeau en position: absolute; inset: 0 ; dégradé sombre du bas vers le haut sous le titre, aria-hidden="true" sur la racine.

Notre fermentation

48 heures de patience

Nos grains de kéfir transforment le sucre des fruits en fines bulles. Aucun gaz ajouté : les levures font le travail, à température ambiante.

22 °Cen cave 48 hde fermentation

Grossissement × 400 — levures au travail

② Levures qui bourgeonnent au microscope — page « Notre fermentation » d'un producteur de kéfir de fruits

QuandAu milieu de la page, un encart rond façon oculaire montre des cellules pâles qui se collent, se séparent, une petite qui se détache d'une grosse ; légende « Grossissement × 400 — levures au travail ».
PourquoiLe cœur qui s'éclaircit là où deux cellules se recouvrent et le bord qui reste net ressemblent à une image de microscope ; un flou CSS donnerait des taches baveuses sans contour.
Réglagesdata-balls="5", data-color="222,212,178" (crème) ; racine à 200 % de l'oculaire réduite de moitié par transform: scale(.5), pour des cellules deux fois plus petites et plus lentes ; oculaire rond de 245 px, bord épais et vignettage sombre en dégradé radial.
Doseur rayon 2 Agitation

Vert sauge 0417

Base mat velours · 2,5 L · 4 colorants

Agitation :2 min 30

③ Mise à la teinte en cours — écran du doseur de colorants au rayon peinture d'un magasin de bricolage

QuandLe vendeur a lancé la teinte « Vert sauge 0417 » : l'écran de la machine affiche le nom de la teinte et « Agitation : 2 min 30 », sur un fond où des masses vert sauge s'agglomèrent et se divisent dans tout le cadre.
PourquoiLa couleur des boules est une simple valeur R,V,B : la machine la règle sur la teinte commandée, et les masses qui se mélangent sur toute la surface figurent le colorant qui se disperse dans le pot.
Réglagesdata-color="132,162,128", six boules (réglage par défaut) ; bloc plein cadre, bandeaux du haut et du bas en noir à 86 % posés par-dessus ; pastille de la teinte et barre d'avancement statiques.

Comment ça marche

Le bloc .fx-0391-root fait 250 px de haut et toute la largeur disponible, fond rgb(26, 26, 36). Il contient un seul <canvas class="metaballs-canvas"> en display: block, à 100 % de son conteneur : c'est le cadre qui fixe la taille, le canvas s'y cale. Le script crée six boules de 30 à 70 px de rayon, placées au hasard à l'intérieur du cadre, chacune avec une vitesse de -1 à 1 px par image sur chaque axe. À chaque image, elles avancent et rebondissent sur les quatre bords ; une boule ne sort jamais du cadre, même quand il rétrécit.

Le dessin est un calcul de champ, pas une forme. Pour chaque pixel, le script additionne la contribution de chaque boule, r² / (d² + 1) (rayon au carré sur distance au carré) : elle vaut 1 sur le bord d'une boule seule et décroît en s'éloignant. Là où la somme dépasse 1, le pixel prend la couleur des boules (99, 102, 241), éclaircie jusqu'à +50 sur chaque canal là où la somme approche 2 ; ailleurs, la couleur du fond. Quand deux boules s'approchent, leurs champs s'additionnent entre elles : la somme franchit 1 avant le contact et un pont de matière se forme, puis les deux contours se fondent en une seule masse. C'est une iso-surface (la ligne où le champ vaut exactement 1), le principe des « metaballs » des logiciels 3D, sans filtre CSS ni flou.

Le calcul étant fait pixel par pixel, sa taille est maîtrisée. Sur écran Retina, le canvas compte jusqu'à 2 pixels par pixel CSS (1 280 × 500 pour un bloc de 640 × 250, net), dans la limite de 650 000 pixels calculés par image : au-delà, la densité redescend vers 1 pour garder la fluidité. Mesuré par image : 1,5 ms dans Chromium, 3 ms dans Firefox et 1 ms dans WebKit à 640 × 250 ; environ 6, 9 et 4 ms sur Retina ou pour un bloc de 1 200 × 500. Le script anime chaque .fx-0391-root de la page séparément ; un IntersectionObserver met la boucle en pause dès que le bloc sort de l'écran (0 image calculée, mesuré) et elle s'arrête aussi quand l'onglet est caché. Un ResizeObserver recalcule le canvas quand le bloc change de taille : 20 redimensionnements de suite, 0 px de dérive. Deux réglages se posent en attributs sur la racine : data-balls (nombre de boules) et data-color (couleur en R,V,B).

Accessibilité

  • Mouvement réduit : sous prefers-reduced-motion: reduce, la boucle ne démarre pas. Le canvas affiche une seule image, les boules à leur place de départ ; mesuré dans Chromium, Firefox et WebKit, 0 % de pixels qui changent en 500 ms. Les formes restent visibles, rien ne bouge.
  • Contenu décoratif : le canvas ne porte aucune information. Dans un bandeau ou derrière un titre, ajoutez aria-hidden="true" sur la racine, comme dans les exemples ; si les boules illustrent quelque chose (des cellules, un mélange), donnez au canvas role="img" et un aria-label qui dit ce qu'on voit.
  • Contraste : les boules indigo sur le fond rgb(26, 26, 36) se distinguent nettement. Un texte posé dessus passe tantôt sur le fond sombre, tantôt sur une boule claire : gardez-le sur un voile sombre ou une ombre portée, comme le dégradé du premier exemple.
  • Aucune interaction : l'effet ne réagit ni à la souris, ni au doigt, ni au clavier. Il n'y a rien à atteindre ni à manquer, il ne gêne pas la navigation au clavier de la page.

Compatibilité navigateur

Canvas 2D (createImageData, putImageData), ResizeObserver, IntersectionObserver, matchMedia : 5 Ko de JavaScript, aucune dépendance. Mesuré dans Chromium 151, Firefox 153 et WebKit 26 : même mouvement, canvas à 1 280 × 500 sur écran Retina, pause hors écran, image fixe sous mouvement réduit, 0 erreur. Les vitesses sont exprimées par image affichée : sur un écran à 120 Hz, les boules vont deux fois plus vite qu'à 60 Hz.

Chrome 64+✓ Complet
Firefox 69+✓ Complet
Safari 13.1+✓ Complet
Edge 79+✓ Complet
Mobile iOS 13.4+✓ Complet
Android Chrome 64+✓ Complet

Sans ResizeObserver, le script suit l'événement resize de la fenêtre ; sans IntersectionObserver, la boucle continue hors écran. Sans JavaScript, le bloc reste un rectangle sombre de 250 px, sans boules.

Le code

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

HTML
<div class="fx-0391-root">
  <div class="metaballs-container">
    <canvas class="metaballs-canvas"></canvas>
  </div>
</div>
CSS
.fx-0391-root,
.fx-0391-root *,
.fx-0391-root *::before,
.fx-0391-root *::after {
  box-sizing: border-box
}

.fx-0391-root {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(26, 26, 36);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

.metaballs-container {
  width: 100%;
  height: 100%;
  position: relative;
}

.metaballs-canvas {
  display: block;
  width: 100%;
  height: 100%;
}
JavaScript (fx-0391)
(function() {
  // ============================================
  // METABALLS - Canvas 2D, champ calculé pixel par pixel
  // Réglages par bloc (facultatifs) sur .fx-0391-root :
  //   data-balls="3"            nombre de boules (6 par défaut)
  //   data-color="99,102,241"   couleur des boules en R,V,B (indigo par défaut)
  // ============================================
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');

  document.querySelectorAll('.fx-0391-root').forEach(function(root) {
    var canvas = root.querySelector('.metaballs-canvas');
    if (!canvas) return;
    var ctx = canvas.getContext('2d');
    var container = canvas.parentElement;

    var numBalls = parseInt(root.getAttribute('data-balls'), 10) || 6;
    var color = (root.getAttribute('data-color') || '99,102,241').split(',').map(Number);
    var background = [26, 26, 36];
    var PIXEL_BUDGET = 650000;

    var w = 0, h = 0, scale = 1, imageData = null;
    var balls = [];
    var raf = 0, visible = true;

    function resize() {
      w = container.clientWidth;
      h = container.clientHeight;
      // Écran Retina : jusqu'à 2 pixels de canvas par pixel CSS, dans la limite de PIXEL_BUDGET pixels
      // calculés par image (le champ est calculé pixel par pixel : sur un grand cadre, la fluidité passe d'abord)
      scale = Math.max(1, Math.min(window.devicePixelRatio || 1, 2, Math.sqrt(PIXEL_BUDGET / Math.max(1, w * h))));
      canvas.width = Math.max(1, Math.round(w * scale));
      canvas.height = Math.max(1, Math.round(h * scale));
      imageData = ctx.createImageData(canvas.width, canvas.height);
      if (!balls.length) {
        for (var i = 0; i < numBalls; i++) {
          var radius = 30 + Math.random() * 40;
          balls.push({
            x: radius + Math.random() * Math.max(0, w - 2 * radius),
            y: radius + Math.random() * Math.max(0, h - 2 * radius),
            vx: (Math.random() - 0.5) * 2,
            vy: (Math.random() - 0.5) * 2,
            radius: radius
          });
        }
      }
      keepInside();
      drawMetaballs();
      start();
    }

    // Une boule ne sort jamais du cadre, même après un rétrécissement
    function keepInside() {
      for (var i = 0; i < balls.length; i++) {
        var ball = balls[i];
        ball.x = w > 2 * ball.radius ? Math.min(Math.max(ball.x, ball.radius), w - ball.radius) : w / 2;
        ball.y = h > 2 * ball.radius ? Math.min(Math.max(ball.y, ball.radius), h - ball.radius) : h / 2;
      }
    }

    function drawMetaballs() {
      var W = canvas.width, H = canvas.height;
      var data = imageData.data;
      var n = balls.length;
      var bx = [], by = [], r2 = [], dy2 = [];
      for (var i = 0; i < n; i++) {
        bx[i] = balls[i].x * scale;
        by[i] = balls[i].y * scale;
        r2[i] = balls[i].radius * balls[i].radius * scale * scale;
      }
      var soft = scale * scale; // le « + 1 » de r² / (d² + 1), en pixels de l'écran
      var idx = 0;

      for (var y = 0; y < H; y++) {
        for (i = 0; i < n; i++) dy2[i] = (y - by[i]) * (y - by[i]) + soft;
        for (var x = 0; x < W; x++) {
          var sum = 0;
          for (i = 0; i < n; i++) {
            var dx = x - bx[i];
            sum += r2[i] / (dx * dx + dy2[i]);
          }

          if (sum > 1) {
            var intensity = Math.min(sum - 1, 1) * 50; // le cœur s'éclaircit là où les champs s'additionnent
            data[idx] = color[0] + intensity;
            data[idx + 1] = color[1] + intensity;
            data[idx + 2] = color[2] + intensity;
          } else {
            data[idx] = background[0];
            data[idx + 1] = background[1];
            data[idx + 2] = background[2];
          }
          data[idx + 3] = 255;
          idx += 4;
        }
      }

      ctx.putImageData(imageData, 0, 0);
    }

    function updateBalls() {
      for (var i = 0; i < balls.length; i++) {
        var ball = balls[i];
        ball.x += ball.vx;
        ball.y += ball.vy;

        if (ball.x < ball.radius) ball.vx = Math.abs(ball.vx);
        if (ball.x > w - ball.radius) ball.vx = -Math.abs(ball.vx);
        if (ball.y < ball.radius) ball.vy = Math.abs(ball.vy);
        if (ball.y > h - ball.radius) ball.vy = -Math.abs(ball.vy);
      }
      keepInside();
    }

    function animate() {
      raf = 0;
      updateBalls();
      drawMetaballs();
      start();
    }

    // La boucle ne tourne que si le bloc est à l'écran, l'onglet visible et le mouvement autorisé
    function start() {
      if (!raf && visible && !document.hidden && !reduce.matches) raf = requestAnimationFrame(animate);
    }

    if ('IntersectionObserver' in window) {
      new IntersectionObserver(function(entries) {
        visible = entries[0].isIntersecting;
        if (visible) start();
        else if (raf) { cancelAnimationFrame(raf); raf = 0; }
      }).observe(root);
    }
    document.addEventListener('visibilitychange', start);

    if ('ResizeObserver' in window) new ResizeObserver(resize).observe(container);
    else window.addEventListener('resize', resize);
    resize();
  });
})();

Personnaliser

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

Option / propriétéDéfautEffet
Nombre de boules (HTML — data-balls sur .fx-0391-root) 6 2 ou 3 boules racontent une rencontre ; 8 et plus remplissent un petit cadre d'une seule masse. Le temps de calcul croît avec le nombre : 12 boules coûtent deux fois plus que 6.
Couleur (HTML — data-color, trois nombres de 0 à 255) 99,102,241 (indigo) data-color="132,162,128" pour un vert sauge. Le cœur s'éclaircit de +50 sur chaque canal : une couleur déjà très claire y sature au blanc.
Couleur du fond (JS — var background = [26, 26, 36]) 26, 26, 36 C'est la couleur des pixels hors des boules. Changez aussi le background de .fx-0391-root pour qu'il n'y ait pas de bord visible pendant le premier affichage.
Taille des boules (JS — 30 + Math.random() * 40) rayon de 30 à 70 px 15 + Math.random() * 20 donne des gouttes de 15 à 35 px, qui fusionnent moins souvent. Entre deux boules de même rayon, le pont de matière apparaît quand leurs centres sont à moins de 2,8 rayons, soit des bords encore séparés de 0,8 rayon.
Vitesse (JS — (Math.random() - 0.5) * 2) -1 à 1 px par image sur chaque axe * 1 au lieu de * 2 divise la vitesse par deux. Une scène lente se lit mieux derrière un texte.
Netteté contre fluidité (JS — var PIXEL_BUDGET = 650000) 650 000 pixels calculés par image Au-delà, la densité Retina redescend vers 1 pixel par pixel CSS. Baissez-la (300 000) pour un bandeau plein écran sur des téléphones modestes ; montez-la pour un bloc large et net sur un ordinateur puissant.
Hauteur (CSS — height de .fx-0391-root) 250px Le canvas suit la hauteur du bloc. Pour un fond de bandeau, placez la racine en position: absolute; inset: 0 dans le bandeau.

FAQ

Pourquoi un canvas et pas un filtre « goo » en CSS ?

Le filtre goo (feGaussianBlur puis feColorMatrix) floute des formes puis durcit le flou : le résultat ressemble à des metaballs, mais le bord dépend du flou et de la couleur de fond, et le cœur reste uni. Ici le contour est calculé : il passe exactement là où le champ vaut 1, quelle que soit la couleur, et le cœur s'éclaircit là où les champs se cumulent. En contrepartie, chaque pixel est calculé à chaque image : le script limite ce travail à 650 000 pixels et s'arrête hors écran.

Combien de blocs peut-on mettre sur une page ?

Le script anime chaque .fx-0391-root de la page, chacun avec ses propres réglages data-balls et data-color. Seuls les blocs visibles calculent : un bloc sorti de l'écran ne coûte rien. Deux blocs de 640 × 250 visibles en même temps sur un écran Retina demandent environ 12 ms par image dans Chromium : c'est la limite raisonnable.

Les boules peuvent-elles suivre la souris ?

Pas dans ce code, qui n'écoute aucun évènement. Pour qu'une boule suive le pointeur, écoutez pointermove sur la racine et écrivez la position (moins getBoundingClientRect()) dans balls[0].x et balls[0].y, en mettant sa vitesse à 0 : elle tirera un pont de matière vers les autres en passant près d'elles.