Pool caustics in pure SVG: two feTurbulence layers, thresholded and then combined in screen mode, whose intersections draw the bright veins you see on the bottom of a pool. No image, no canvas — the filter drifts on its own and a soft glow follows the cursor.
SVG feTurbulencePointermix-blend-mode
Hover or click the scene to interact
This effect is free — the Backgrounds category contains 50 effects total, including 6 free. Effect.Labs has 811 vanilla effects. Explore the category →
WhenThe home page must evoke water within the first second, without shipping a 400 KB photo that shifts the headline while it loads.
WhyCaustics are one of the few visual cues the brain maps instantly to "swimming pool", and they fit in a few kilobytes of markup. The slow drift — a full cycle takes over a minute — reads as calm rather than busy.
SettingsKeep the default drift and place the headline on a semi-opaque band rather than directly on the veins. Lower AMP1X/AMP2X to 20 if the motion pulls attention away from the copy.
🏆
② Section background for a water-related product
WhenA bottle, a sunscreen, a swimsuit, a purifier: the product page needs an aquatic context that does not upstage the product shot.
WhyUnlike a background video, the filter has no first frame to load, needs no decoding, and pauses with the rest of the page when the tab goes to the background.
SettingsDrop the container opacity to 0.5 and raise the final feGaussianBlur to soften the veins: the background becomes a texture rather than a subject.
🚀
③ Loading screen or "coming soon" page
WhenA screen that stays up for several seconds, where a static background looks frozen and a fast animation looks nervous.
WhyThe drift is aperiodic: the two layers advance at incommensurable speeds (0.14 and 0.19), so the pattern never repeats exactly. The eye finds no loop, even after a long wait.
SettingsRaise speedMult to 1.6 for livelier water, and leave the cursor glow on: it gives visitors immediate feedback that the page is alive.
How it works
Two feTurbulence layers run in parallel: a wide one (8 octaves, frequency 0.0044) for the large swells, a fine one (5 octaves, 0.0074) for the tight mesh. Each produces continuous noise — not veins yet.
An feComponentTransfer "tent" isolates the crests around noise ≈ 0.5, then feGamma (exponents 4 and 5) chokes what remains: only the peaks survive. That pair of operations is what turns a blurry cloud into crisp filaments.
feBlend mode="screen" stacks the two layers — their intersections become the brightest points, exactly as two wave trains crossing would. The requestAnimationFrame loop then drifts the feOffset values (±36 px and ±44 px) and lets the baseFrequency breathe, which produces the current.
Accessibility
The animation stops under prefers-reduced-motion: reduce: the filter stays visible, frozen on a stable frame, rather than disappearing. A still background beats an absent one.
The effect also honours prefers-reduced-transparency: reduce — rarer, but decisive here: caustics rely on translucent stacking, and a visitor who turned transparency off gets a flat surface instead of an unreadable pile.
The SVG filter block carries aria-hidden="true" and zero size: it never enters the reading order or the layout calculation.
This is background decoration: never place text on it without checking contrast. The brightest veins climb high in luminance, and grey text will drop below 4.5:1 in places without that showing on a still screenshot.
Browser compatibility
Relies on the SVG filter primitives feTurbulence, feOffset, feComponentTransfer, feGamma, feBlend and feGaussianBlur — all standard SVG 1.1 — plus requestAnimationFrame and matchMedia. No dependency, no canvas, no image.
Chrome 80+✓ Full
Edge 80+✓ Full
Firefox 75+✓ Full
Safari 14+✓ Full
Safari iOS 14+✓ Full — filter is pricier on mobile, see the FAQ
Chrome Android 80+✓ Full
Without JavaScript the filter still applies but stops drifting: you get a fixed caustics image, which remains a credible background. On a browser with no SVG filter support the element simply shows its background colour — so pick a base colour that reads well on its own.
The code
Copy the three blocks into your page. No dependencies.
(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');
});
}
})();
Customize
Options passed to the API or data-* attributes:
Option / property
Default
Effect
BF1X / BF1Y — wide layer frequency
0.0044 / 0.0033
Drives the scale of the large veins. Halve it for a pool that reads as deeper (broader patterns); double it for a tighter texture, closer to shallow water.
BF2X / BF2Y — fine layer frequency
0.0074 / 0.0058
Density of the fine mesh. This is what produces the bright intersections: the closer it sits to the wide layer, the rarer and larger those highlights become.
AMP1X, AMP1Y, AMP2X, AMP2Y — drift amplitude
36, 28, 44, 34 px
How far each layer travels. The turbulence repeats every ~120 px: past 50, the pattern folds back on itself and the loop becomes noticeable.
speedMult — global speed
1.0
Multiplies the phase advance, never the position — you can change it mid-animation without causing a jump. 0.4 gives very calm water, 2.0 an agitated surface.
LERP — cursor glow inertia
0.08
Fraction of the distance closed each frame. 0.03 gives a very lazy glow, 0.25 one almost glued to the pointer. Past 0.4 the inertia stops being visible.
feGamma exponent
4 and 5
The filter's most sensitive control. Dropping to 2 widens the veins into a haze; raising it to 7 leaves only thin filaments on a near-black field.
FAQ
Is this filter expensive? I worry about phones.
More than a gradient, less than a video. The real cost comes from the filtered area: a 1200 × 400 px band is fine everywhere, a full-screen background on a low-end phone is noticeable. Two levers if needed: shrink the filtered element and stretch it in CSS, or raise the feGaussianBlur so you can lower the frequencies. Measure on a real device rather than an emulator — SVG filters are one of the few areas where the gap is enormous.
Can I change the colour of the water?
Yes, and that is the right place to customise. The filter produces luminance; the hue comes from the background and the coloured layer above it. Change the container's background colour and the overlaid gradient — turquoise for a pool, deep green for a lagoon, night blue for a lit basin. Do not touch the filter itself for colour; you would break the thresholding.
Why two turbulence layers instead of one?
Because a single thresholded layer gives a regular lattice that instantly reads as a generated texture. Real caustics come from several wave trains crossing: their intersections are what concentrate the light. The feBlend in screen mode reproduces that crossing, and it is the difference between "noise" and "water".