Waterrimpel over een foto Nieuw
Over een foto van de gracht trekken rimpels mee met je muis of vinger, alsof je met je hand door het water gaat; een klik geeft een grotere golf en als niemand iets doet valt er af en toe vanzelf een druppel. Voor de hero van een sloepverhuur, zwemschool, sauna, viswinkel of rederij. Eén fragment-shader met OGL; zonder WebGL of met minder beweging staat er gewoon de foto.
Zo gebruik je het
- Plak de HTML op de plek waar het onderdeel moet komen. Zet de script-tags bovenaan één keer per pagina.
- Zet de CSS in je stylesheet. Alle klassen hebben een eigen voorvoegsel, dus niets botst met je eigen opmaak.
- Voeg de JavaScript toe en roep init() aan met het element waar de HTML in staat.
<!-- 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'));
Meer uit achtergronden & webgl
Iets gezien dat je mooi vindt?
Zet het in je selectie en stuur hem op. Dan bouwen we je site eromheen, in jouw huisstijl.
Neem contact op →