Waterrimpel over een foto New
Ripples follow your mouse or finger across a photo of the canal, as if you trail your hand through the water; a click makes a bigger wave and when nobody does anything a drop falls now and then. For the hero of a boat rental, swimming school, sauna, fishmonger or ferry company. One fragment shader with OGL; without WebGL or with reduced motion you simply see the photo.
How to use it
- Paste the HTML where the backgrounds & webgl should appear. Include the script tags at the top once per page.
- Add the CSS to your stylesheet. All classes are prefixed, so nothing clashes with your own styles.
- Add the JavaScript and call init() with the element that contains the HTML.
<!-- Waterrimpel over een foto · © 2026 NedDev BV · NedDev Lab-licentie (gratis gebruik, niet doorverkopen) · neddev.com/nl/lab/gebruik/#licentie -->
<!-- benodigd, één keer per pagina: -->
<script type="module">import * as OGL from "https://esm.sh/ogl@1.0.11"; window.OGL = OGL;</script>
<div class="jouw-container">
<div class="rmf">
<img class="rmf__img" src="https://neddev.com/lab-media/rimpel-over-foto-1.webp" alt="Gracht in Utrecht met een houten sloep aan de werfkade" width="1400" height="764" loading="lazy" decoding="async">
<div class="rmf__txt"><p class="rmf__h">Sloepverhuur aan de Oudegracht</p><p class="rmf__p">Utrecht · vanaf € 95 per dagdeel</p><a class="rmf__btn" href="#">Boek een sloep</a></div>
</div>
</div>/* Waterrimpel over een foto · © 2026 NedDev BV · NedDev Lab-licentie (gratis gebruik, niet doorverkopen) · neddev.com/nl/lab/gebruik/#licentie */
.rmf{position:absolute;inset:0;background:#0a0a0a;font-family:Switzer,sans-serif;color:#fff;overflow:hidden}
.rmf__img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.rmf__cv{position:absolute;inset:0;width:100%;height:100%;display:block;opacity:0;transition:opacity .4s}
.rmf.is-gl .rmf__cv{opacity:1}
.rmf::after{content:"";position:absolute;inset:auto 0 0 0;height:62%;background:linear-gradient(0deg,rgba(10,10,10,.78),rgba(10,10,10,0));pointer-events:none}
.rmf__txt{position:absolute;left:16px;right:16px;bottom:14px;z-index:1}
.rmf__h{margin:0;font:600 18px/1.2 Switzer,sans-serif}
.rmf__p{margin:4px 0 10px;font:400 14px/1.35 Switzer,sans-serif;color:#eceae6}
.rmf__btn{display:inline-flex;align-items:center;min-height:36px;padding:0 14px;border:0;border-radius:4px;font:600 14px/1 Switzer,sans-serif;cursor:pointer;text-decoration:none;background:#fff;color:#0a0a0a;transition:background-color .18s,color .18s}
.rmf__btn:hover{background:#F5480F;color:#0a0a0a}
.rmf__btn:focus-visible{outline:2px solid #fff;outline-offset:3px}
@media (prefers-reduced-motion: reduce){.rmf__btn,.rmf__cv{transition:none}}// Waterrimpel over een foto · © 2026 NedDev BV · NedDev Lab-licentie (gratis gebruik, niet doorverkopen) · neddev.com/nl/lab/gebruik/#licentie
// root = het element waarin de HTML staat
function init(root){
var reduce=!!(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches);
function fit(cv,ctx,cb){var dpr=Math.min(window.devicePixelRatio||1,2);function f(){var r=cv.getBoundingClientRect();var w=Math.max(1,Math.round(r.width)),h=Math.max(1,Math.round(r.height));if(cv.width===Math.round(w*dpr)&&cv.height===Math.round(h*dpr))return;cv.width=Math.round(w*dpr);cv.height=Math.round(h*dpr);if(ctx)ctx.setTransform(dpr,0,0,dpr,0,0);if(cb)cb(w,h,dpr);}if(window.ResizeObserver)new ResizeObserver(f).observe(cv);f();}
function loop(el,step){var on=false,raf=0,last=0;function fr(t){raf=requestAnimationFrame(fr);var dt=last?Math.min(.05,(t-last)/1000):.016;last=t;step(dt,t/1000);}function go(v){if(v&&!on){on=true;last=0;raf=requestAnimationFrame(fr);}else if(!v&&on){on=false;cancelAnimationFrame(raf);}}if(window.IntersectionObserver)new IntersectionObserver(function(es){go(es[0].isIntersecting);}).observe(el);else go(true);}
var w = root.querySelector('.rmf'), img = w.querySelector('.rmf__img');
w.querySelector('.rmf__btn').addEventListener('click', function (e) { e.preventDefault(); });
if (reduce || typeof OGL === 'undefined') return;
var cv = document.createElement('canvas'); cv.className = 'rmf__cv'; cv.setAttribute('aria-hidden', 'true');
var renderer;
try { renderer = new OGL.Renderer({ canvas: cv, dpr: Math.min(window.devicePixelRatio || 1, 2), alpha: false }); } catch (e) { return; }
var gl = renderer.gl; if (!gl) return;
img.insertAdjacentElement('afterend', cv);
var tex = new OGL.Texture(gl, { generateMipmaps: false, minFilter: gl.LINEAR, magFilter: gl.LINEAR, wrapS: gl.CLAMP_TO_EDGE, wrapT: gl.CLAMP_TO_EDGE });
var N = 10, R = [], ri = 0;
for (var i = 0; i < N; i++) R.push(0, 0, -99, 0);
var program = new OGL.Program(gl, {
vertex: 'attribute vec2 uv;attribute vec2 position;varying vec2 vUv;void main(){vUv=uv;gl_Position=vec4(position,0.,1.);}',
fragment: 'precision highp float;varying vec2 vUv;uniform sampler2D tMap;uniform vec2 uRes;uniform vec2 uImg;uniform vec4 uR[10];uniform float uT;' +
'void main(){vec2 p=vUv*uRes;vec2 off=vec2(0.);float hl=0.;' +
'for(int i=0;i<10;i++){vec4 r=uR[i];float age=uT-r.z;if(age<0.||age>3.2)continue;vec2 d=p-r.xy;float dist=length(d);float rad=age*150.;' +
'float band=exp(-pow((dist-rad)/(22.+age*18.),2.));float wv=sin((dist-rad)*.2)*band*r.w*exp(-age*1.4);off+=(d/(dist+.001))*wv*13.;hl+=wv;}' +
'vec2 s=uRes/uImg;float sc=max(s.x,s.y);vec2 sz=uImg*sc;vec2 o=(uRes-sz)*.5;vec2 tuv=clamp(((p+off)-o)/sz,0.001,.999);' +
'vec3 c=texture2D(tMap,tuv).rgb;c+=vec3(hl*.12);gl_FragColor=vec4(c,1.);}',
uniforms: { tMap: { value: tex }, uRes: { value: [1, 1] }, uImg: { value: [1400, 764] }, uR: { value: R }, uT: { value: 0 } }
});
var mesh = new OGL.Mesh(gl, { geometry: new OGL.Triangle(gl), program: program });
var ok = false, W = 1, H = 1, last = { x: -999, y: -999, t: 0 }, lastMove = 0;
function size() { var r = w.getBoundingClientRect(); W = Math.max(1, r.width); H = Math.max(1, r.height); renderer.setSize(W, H); program.uniforms.uRes.value = [W, H]; }
if (window.ResizeObserver) new ResizeObserver(size).observe(w); size();
function setImg() {
// Beeld van een ander domein zonder CORS mag niet in WebGL: dan blijft gewoon de foto staan.
try { var t2 = document.createElement('canvas'); t2.width = t2.height = 1; var c2 = t2.getContext('2d'); c2.drawImage(img, 0, 0, 1, 1); c2.getImageData(0, 0, 1, 1); } catch (e) { cv.remove(); return; }
tex.image = img; program.uniforms.uImg.value = [img.naturalWidth, img.naturalHeight]; ok = true; w.classList.add('is-gl'); }
if (img.complete && img.naturalWidth) setImg(); else img.addEventListener('load', setImg);
function now() { return performance.now() / 1000; }
function add(x, y, s) { var k = (ri++ % N) * 4; R[k] = x; R[k + 1] = H - y; R[k + 2] = now(); R[k + 3] = s; }
w.addEventListener('pointermove', function (e) {
var r = w.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top, tn = now();
lastMove = tn;
if (Math.hypot(x - last.x, y - last.y) > 22 && tn - last.t > .07) { add(x, y, .55); last = { x: x, y: y, t: tn }; }
});
w.addEventListener('pointerdown', function (e) { var r = w.getBoundingClientRect(); add(e.clientX - r.left, e.clientY - r.top, 1.3); lastMove = now(); });
var auto = 0;
loop(w, function (dt) {
if (!ok) return;
var tn = now(); auto -= dt;
if (auto <= 0 && tn - lastMove > 3) { auto = 2.6; add(W * (.3 + Math.random() * .6), H * (.62 + Math.random() * .3), 1); }
program.uniforms.uT.value = tn; renderer.render({ scene: mesh });
});
}
init(document.querySelector('.jouw-container'));
More backgrounds & webgl
Seen something you like?
Add it to your selection and send it over. We build your site around it, in your brand.
Get in touch →