Des caustiques de piscine en SVG pur : deux couches de feTurbulence seuillées puis fusionnées en screen, dont les intersections dessinent les veines lumineuses qu'on voit au fond de l'eau. Aucune image, aucun canvas — le filtre dérive tout seul et un halo doux suit le curseur.
SVG feTurbulencePointermix-blend-mode
Survolez ou cliquez la scène pour interagir
Cet effet est gratuit — la catégorie Backgrounds en contient 50 au total, dont 6 gratuits. Effect.Labs regroupe 811 effets vanilla. Découvrir la catégorie →
① En-tête d'un site d'hôtel, de spa ou de camping avec piscine
QuandLa page d'accueil doit évoquer l'eau dès la première seconde, sans imposer une photo de 400 Ko qui décalera le titre le temps de se charger.
PourquoiLes caustiques sont l'un des rares signaux visuels que le cerveau associe immédiatement à « piscine », et ils tiennent en quelques kilo-octets de balisage. Le mouvement lent — une dérive complète prend plus d'une minute — évoque le calme plutôt que l'agitation.
RéglagesGardez la dérive par défaut et posez le titre dans une bande semi-opaque plutôt que directement sur les veines. Baissez AMP1X/AMP2X à 20 si le mouvement distrait de la lecture.
🏆
② Fond de section pour un produit lié à l'eau
QuandUne gourde, une crème solaire, un maillot, un purificateur : la fiche produit a besoin d'un contexte aquatique qui ne vole pas la vedette à la photo du produit.
PourquoiContrairement à une vidéo de fond, le filtre n'a pas de première image à charger, ne réclame aucun décodage et se met en pause avec le reste de la page quand l'onglet passe en arrière-plan.
RéglagesRéduisez l'opacité du conteneur à 0,5 et augmentez le feGaussianBlur final pour adoucir les veines : le fond devient une texture plutôt qu'un sujet.
🚀
③ Écran de chargement ou page « bientôt disponible »
QuandUn écran qui reste plusieurs secondes à l'affichage, où un fond fixe paraît figé et une animation trop rapide paraît nerveuse.
PourquoiLa dérive est apériodique : les deux couches avancent à des vitesses incommensurables (0,14 et 0,19), donc le motif ne se répète jamais exactement. L'œil ne repère aucune boucle, même après une longue attente.
RéglagesMontez speedMult à 1,6 pour une eau plus vive, et laissez le halo du curseur actif : il donne au visiteur un retour immédiat que la page est vivante.
Comment ça marche
Deux feTurbulence tournent en parallèle : une couche large (8 octaves, fréquence 0,0044) pour les grandes ondes, une couche fine (5 octaves, 0,0074) pour les mailles serrées. Chacune produit un bruit continu, pas encore des veines.
Un feComponentTransfer en « tente » isole les crêtes autour de noise ≈ 0,5, puis feGamma (exposant 4 et 5) étrangle ce qui reste : seuls les pics survivent. C'est cette double opération qui transforme un nuage flou en filaments nets.
feBlend mode="screen" superpose les deux couches — leurs intersections deviennent les points les plus lumineux, exactement comme deux trains d'ondes qui se croisent. La boucle requestAnimationFrame fait ensuite dériver les feOffset (±36 px et ±44 px) et respirer les baseFrequency, ce qui donne le courant.
Accessibilité
L'animation s'arrête sous prefers-reduced-motion: reduce : le filtre reste affiché, figé sur une image stable, au lieu de disparaître. Un fond immobile est préférable à un fond absent.
L'effet respecte aussi prefers-reduced-transparency: reduce, plus rare mais décisif ici : les caustiques reposent sur des superpositions translucides, et un visiteur qui a désactivé la transparence voit une surface unie plutôt qu'un empilement illisible.
Le bloc SVG des filtres porte aria-hidden="true" et une taille nulle : il ne rentre jamais dans l'ordre de lecture ni dans le calcul de la mise en page.
C'est une décoration de fond : ne posez jamais de texte dessus sans vérifier le contraste. Les veines les plus claires montent haut en luminance, et un texte gris passera sous le seuil de 4,5:1 par endroits sans que ce soit visible sur une capture fixe.
Compatibilité navigateur
Repose sur les primitives de filtre SVG feTurbulence, feOffset, feComponentTransfer, feGamma, feBlend et feGaussianBlur, toutes au standard SVG 1.1, plus requestAnimationFrame et matchMedia. Aucune dépendance, aucun canvas, aucune image.
Chrome 80+✓ Complet
Edge 80+✓ Complet
Firefox 75+✓ Complet
Safari 14+✓ Complet
Safari iOS 14+✓ Complet — filtre plus coûteux sur mobile, voir la FAQ
Chrome Android 80+✓ Complet
Sans JavaScript, le filtre reste appliqué mais ne dérive plus : vous obtenez une image de caustiques fixe, qui reste un fond crédible. Sur un navigateur sans support des filtres SVG, l'élément affiche simplement sa couleur de fond — prévoyez donc une couleur de base lisible sous le filtre.
Le code
Copiez les trois blocs dans votre page. Aucune dépendance.
(function () {
'use strict';
/* ---------- Détection préférences ---------- */
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var reduceTrans = window.matchMedia('(prefers-reduced-transparency: reduce)').matches;
/* ---------- Éléments SVG du filtre (communs aux deux scènes) ---------- */
var off1 = document.getElementById('pcaOff1');
var off2 = document.getElementById('pcaOff2');
var turb1 = document.getElementById('pcaTurb1');
var turb2 = document.getElementById('pcaTurb2');
/* ---------- Éléments scène principale ---------- */
var scene = document.getElementById('pcaScene');
var glow = document.getElementById('pcaGlow');
/* ---------- Éléments scène exemple d'usage ---------- */
var sceneEx = document.getElementById('pcaSceneEx');
var glowEx = document.getElementById('pcaGlowEx');
/* ---------- État pointeur (pour chaque scène) ---------- */
var LERP = 0.08;
var ptr = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, in: false };
var ptrEx = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, in: false };
/* ---------- Paramètres animation caustiques ---------- */
/* Fréquences de base des deux couches de turbulence */
var BF1X = 0.0044, BF1Y = 0.0033;
var BF2X = 0.0074, BF2Y = 0.0058;
/* Amplitude des oscillations de baseFrequency (12% env.) */
var DAMP = 0.001;
var DAMP2 = 0.0014;
/* Amplitude dérive feOffset (px). La turbulence se répète tous les ~120px,
donc ±35px offre une dérive riche sans revenir trop vite au départ. */
var AMP1X = 36, AMP1Y = 28;
var AMP2X = 44, AMP2Y = 34;
var speedMult = 1.0; /* contrôlé par le slider de review */
var phase = 0, lastTs = 0; /* phase accumulée : la vitesse module l'AVANCE, jamais la position (aucun saut) */
function lerp(a, b, t) { return a + (b - a) * t; }
/* Met à jour les custom props de position du glow dans une scène */
function setGlowPos(glowEl, p) {
if (!glowEl) return;
var x = (p.x * 100).toFixed(1) + '%';
var y = (p.y * 100).toFixed(1) + '%';
glowEl.style.setProperty('--pca-px', x);
glowEl.style.setProperty('--pca-py', y);
if (p.in) {
glowEl.classList.add('pca-active');
} else {
glowEl.classList.remove('pca-active');
}
}
/* ---------- Boucle principale rAF ---------- */
function tick(ts) {
if (!lastTs) lastTs = ts;
var dt = Math.min((ts - lastTs) * 0.001, 0.05);
lastTs = ts;
phase += dt * speedMult;
/* Dérive sinusoïdale des offsets SVG (crée le "courant" des caustiques) */
if (!reduceMotion && !reduceTrans && off1 && off2) {
var dx1 = Math.sin(phase * 0.14) * AMP1X + Math.sin(phase * 0.09) * (AMP1X * 0.4);
var dy1 = Math.cos(phase * 0.11) * AMP1Y + Math.cos(phase * 0.07) * (AMP1Y * 0.35);
off1.setAttribute('dx', dx1.toFixed(1));
off1.setAttribute('dy', dy1.toFixed(1));
var dx2 = Math.sin(phase * 0.19 + 0.8) * AMP2X + Math.sin(phase * 0.13 + 0.4) * (AMP2X * 0.35);
var dy2 = Math.cos(phase * 0.15 + 1.2) * AMP2Y + Math.cos(phase * 0.10 + 0.9) * (AMP2Y * 0.30);
off2.setAttribute('dx', dx2.toFixed(1));
off2.setAttribute('dy', dy2.toFixed(1));
/* Légère variation de baseFrequency → texture change subtilement au fil du temps */
var f1x = (BF1X + Math.sin(phase * 0.06) * DAMP).toFixed(5) + ' ';
var f1y = (BF1Y + Math.cos(phase * 0.05) * (DAMP * 0.8)).toFixed(5);
turb1 && turb1.setAttribute('baseFrequency', f1x + f1y);
var f2x = (BF2X + Math.sin(phase * 0.08 + 0.5) * DAMP2).toFixed(5) + ' ';
var f2y = (BF2Y + Math.cos(phase * 0.07 + 1.1) * (DAMP2 * 0.75)).toFixed(5);
turb2 && turb2.setAttribute('baseFrequency', f2x + f2y);
}
/* Interpolation des positions pointeur */
if (!reduceMotion) {
ptr.x = lerp(ptr.x, ptr.tx, LERP);
ptr.y = lerp(ptr.y, ptr.ty, LERP);
ptrEx.x = lerp(ptrEx.x, ptrEx.tx, LERP);
ptrEx.y = lerp(ptrEx.y, ptrEx.ty, LERP);
}
if (!reduceTrans) {
setGlowPos(glow, ptr);
setGlowPos(glowEx, ptrEx);
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
/* Normalise les coordonnées pointeur dans [0,1] relativement à une scène */
function normPos(e, el) {
var r = el.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)),
y: Math.max(0, Math.min(1, (e.clientY - r.top) / r.height))
};
}
/* Attache les événements pointeur à une scène */
function bindPointer(sceneEl, ptrState) {
if (!sceneEl || reduceMotion) return;
sceneEl.addEventListener('pointermove', function (e) {
var p = normPos(e, sceneEl);
ptrState.tx = p.x; ptrState.ty = p.y;
/* Le pointeur ne touche PAS à la vitesse du champ (retour Anthony :
le boost global secouait tout et fatiguait l'œil). Le remous local
= glow + anneaux uniquement. */
ptrState.in = true;
});
sceneEl.addEventListener('pointerleave', function () {
ptrState.in = false;
});
sceneEl.addEventListener('touchmove', function (e) { e.preventDefault(); }, { passive: false });
}
bindPointer(scene, ptr);
bindPointer(sceneEx, ptrEx);
/* ---- Contrôles de review uniquement (NE PAS copier) ---- */
var intSlider = document.getElementById('pcaInt');
var intV = document.getElementById('pcaIntV');
var speedSlider = document.getElementById('pcaSpeed');
var speedV = document.getElementById('pcaSpeedV');
var nightBtn = document.getElementById('pcaNight');
var a11yBtn = document.getElementById('pcaA11y');
function allScenes() {
var s = [scene, sceneEx].filter(Boolean);
return s;
}
if (intSlider) {
intSlider.addEventListener('input', function () {
intV.textContent = this.value;
var v = (+this.value / 100).toFixed(3);
allScenes().forEach(function (s) { s.style.setProperty('--pca-op', v); });
});
}
if (speedSlider) {
speedSlider.addEventListener('input', function () {
speedV.textContent = this.value;
speedMult = +this.value / 100;
});
}
if (nightBtn) {
nightBtn.addEventListener('click', function () {
var on = false;
allScenes().forEach(function (s) { on = s.classList.toggle('pca-night'); });
this.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
if (a11yBtn) {
a11yBtn.addEventListener('click', function () {
var on = false;
allScenes().forEach(function (s) { on = s.classList.toggle('pca-solid'); });
this.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
})();
Personnaliser
Options passées à l'API ou attributs data-* :
Option / propriété
Défaut
Effet
BF1X / BF1Y — fréquence de la couche large
0.0044 / 0.0033
Pilote l'échelle des grandes veines. Divisez par deux pour un bassin qui paraît plus profond (motifs plus amples) ; doublez pour une texture plus serrée, façon eau peu profonde.
BF2X / BF2Y — fréquence de la couche fine
0.0074 / 0.0058
Densité du maillage fin. C'est lui qui produit les intersections lumineuses : plus il est proche de la couche large, plus les points brillants se raréfient et grossissent.
AMP1X, AMP1Y, AMP2X, AMP2Y — amplitude de dérive
36, 28, 44, 34 px
Distance parcourue par chaque couche. La turbulence se répète tous les ~120 px : au-delà de 50, le motif revient sur lui-même et la boucle devient perceptible.
speedMult — vitesse globale
1.0
Multiplie l'avance de la phase, jamais la position : vous pouvez le changer en cours d'animation sans provoquer de saut. 0,4 donne une eau très calme, 2,0 une surface agitée.
LERP — inertie du halo au curseur
0.08
Fraction de la distance rattrapée à chaque image. 0,03 donne un halo très paresseux, 0,25 un halo presque collé au pointeur. Au-delà de 0,4 l'inertie ne se voit plus.
Exposant de feGamma
4 et 5
Le réglage le plus sensible du filtre. Descendre à 2 élargit les veines jusqu'à obtenir une brume ; monter à 7 ne laisse que de fins filaments sur un fond presque noir.
FAQ
Ce filtre est-il coûteux ? J'ai peur pour les téléphones.
Il l'est plus qu'un dégradé et moins qu'une vidéo. Le coût réel vient de la surface filtrée : un bandeau de 1 200 × 400 px passe partout, un fond plein écran sur un téléphone d'entrée de gamme se sent. Deux leviers si besoin : réduire la taille de l'élément filtré et l'étirer en CSS, ou augmenter le feGaussianBlur pour pouvoir baisser les fréquences. Mesurez sur un appareil réel plutôt que sur un émulateur : les filtres SVG sont l'un des rares domaines où l'écart est énorme.
Puis-je changer la couleur de l'eau ?
Oui, et c'est le bon endroit pour personnaliser. Le filtre produit une luminance ; la teinte vient du fond et de la couche colorée par-dessus. Changez la couleur de fond du conteneur et celle du dégradé superposé — turquoise pour une piscine, vert sombre pour un lagon, bleu nuit pour un bassin éclairé de nuit. Ne touchez pas au filtre lui-même pour la couleur, vous casseriez le seuillage.
Pourquoi deux couches de turbulence plutôt qu'une seule ?
Parce qu'une seule couche seuillée donne un réseau régulier, qui se lit tout de suite comme une texture générée. Les vraies caustiques naissent du croisement de plusieurs trains d'ondes : ce sont leurs intersections qui concentrent la lumière. Le feBlend en screen reproduit ce croisement, et c'est ce qui fait la différence entre « du bruit » et « de l'eau ».