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 →
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.
Mis à jour le
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 →
1er janvier 2027
Une mairie, un conseil, deux bourgs. Ce qui change pour vos démarches →
Notre fermentation
Nos grains de kéfir transforment le sucre des fruits en fines bulles. Aucun gaz ajouté : les levures font le travail, à température ambiante.
Grossissement × 400 — levures au travail
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).
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.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.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.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.
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.
Copiez les trois blocs dans votre page. Aucune dépendance.
<div class="fx-0391-root">
<div class="metaballs-container">
<canvas class="metaballs-canvas"></canvas>
</div>
</div>
.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%;
}
(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();
});
})();
Options passées à l'API ou attributs data-* :
| Option / propriété | Défaut | Effet |
|---|---|---|
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. |
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.
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.
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.