Building an interactive WebGL water hero in React - and the four bugs it cost me
I wanted a hero section that reacts to being touched. Not a video loop of water, not a looping GIF - an actual surface that ripples where you click it. The effect itself took an afternoon. Everything around it took considerably longer, and that is the part worth writing down. Here is what it ended up looking like: live demo The simulation The heavy lifting is done by jquery.ripples, a small plugin that runs a shallow-water simulation in WebGL. It stores a height field in a texture and steps it with the classic wave update: float average = ( texture2D(texture, coord - dx).r + texture2D(texture, coord - dy).r + texture2D(texture, coord + dx).r + texture2D(texture, coord + dy).r ) * 0.25; info.g += (average - info.r) * 2.0; // velocity info.g = 0.995; // damping info.r += info.g; // height Then it refracts the background image along the surface normal. That is the whole trick. Getting a jQuery plugin into React The plugin is from an era when everything attached itself to a global jQuery . That is awkward inside a bundler, but not hard - import it dynamically and set the global first: useEffect(() => { const node = surfaceRef.current; if (!node) return; let $el = null; let cancelled = false; async function initRipples() { const { default: $ } = await import("jquery"); window.jQuery = $; window.$ = $; await import("jquery.ripples"); if (cancelled) return; $el = $(node); $el.ripples({ resolution: 512, dropRadius: 24, perturbance: 0.026 }); } initRipples(); return () => { cancelled = true; if ($el) $el.ripples("destroy"); }; }, []); Two things matter here. The dynamic import() means jQuery and the plugin end up in their own chunk instead of blocking first paint. And the cancelled flag matters because in StrictMode the effect runs twice in development - without it you can initialise onto an element that is already being torn down. Turning the interaction off By default the plugin ripples wherever your mouse moves. It looks great in a demo GIF and terrible on a real page: the water is in constant motion directly behind your headline, and the eye never settles enough to read it. So I switched its pointer tracking off and fire drops manually instead: $el.ripples({ / ... */ interactive: false }); const dropAt = (clientX, clientY) => { const rect = node.getBoundingClientRect(); $el.ripples("drop", clientX - rect.left, clientY - rect.top, 32, 0.1); }; node.addEventListener("mousedown", (e) => dropAt(e.clientX, e.clientY)); node.addEventListener("touchstart", (e) => { for (const t of e.changedTouches) dropAt(t.clientX, t.clientY); }, { passive: true }); Now the surface is still until someone deliberately touches it. Much calmer, and the interaction feels intentional rather than incidental. The contrast problem nobody warns you about Bright water plus white type is a losing combination. The caustics are almost pure white in places, and thin light type simply disappears into them. Drop shadows on the text help a little. What actually fixed it was a permanent veil across the middle of the overlay gradient: A uniform dark overlay would have killed the effect. Concentrating it where the type sits keeps the water bright at the edges and readable in the middle. The four bugs 1. 100vh is not the viewport on mobile Safari The hero was h-screen , which compiles to height: 100vh . On iOS that resolves to the large viewport - the one you get when the URL bar is hidden. So the bottom of the hero sat underneath the URL bar, and jumped whenever the bar collapsed. 100svh - the small viewport - is always fully visible and never resizes: .h-hero { height: 100vh; } @supports (height: 100svh) { .h-hero { height: 100svh; } } dvh is the tempting one, but it resizes as the bar hides, which reintroduces the jump. svh is the right choice for a hero. 2. Two fast clicks, one stale value The cart had quantity steppers. The decrement handler looked reasonable: onClick={() => setQuantity(product.id, quantity - 1)} quantity comes from props. Click twice quickly enough that both events land in the same React batch and both handlers compute 3 - 1 = 2 . The second click does nothing. The fix is to stop passing absolute values around and resolve the change inside the updater, where the latest state is available: const addItem = useCallback((id, delta = 1) => update((current) => { const existing = current.find((line) => line.id === id); if (!existing) return delta > 0 ? [...current, { id, quantity: delta }] : current; const quantity = existing.quantity + delta; return quantity line.id !== id) : current.map((l) => (l.id === id ? { ...l, quantity } : l)); }), [update]); Now the buttons call addItem(id, +1) and addItem(id, -1) and two clicks always add up. 3. A preload that downloaded 64KB on every page that didn't need it I had put the sensible-looking thing in index.html : This is a single-page app. One index.html serves every route. So /shop , /cart and every product page were all downloading the hero texture - a texture that only ever renders on / . The browser even said so in the console: The resource … was preloaded using link preload but not used within a few seconds from the window's load event. I deleted it. The hero's own background-image request starts as soon as React mounts it, which is soon enough, and nothing else pays for it. 4. Hash links that navigated but never scrolled Clicking [ABOUT] from /shop should land you on the home page at the About section. It navigated fine and stayed at the top. Two reasons, stacked. The target section had not mounted yet when the effect ran - so document.querySelector(hash) returned null . And once I fixed that with a retry, a smooth scrollIntoView got cancelled halfway as the hero image loaded and the page height changed underneath it. What works is re-asserting the position for a short while instead of firing one animation and hoping: useEffect(() => { if (!hash) { window.scrollTo({ top: 0, behavior: "instant" }); return; } let cancelled = false, timer = 0, attempts = 0; const settle = () => { if (cancelled) return; const target = document.querySelector(hash); if (target) { const top = target.getBoundingClientRect().top + window.scrollY; if (Math.abs(window.scrollY - top) > 4) { window.scrollTo({ top, behavior: "instant" }); } } if (++attempts { cancelled = true; window.clearTimeout(timer); }; }, [pathname, hash]); Plain in-page anchors keep the CSS smooth behaviour. Cross-route hashes jump, which is what a browser does for a hash on load anyway. Reduced motion is not optional here A constantly moving water surface is close to the canonical example of what prefers-reduced-motion exists to prevent. The simulation should not merely be slower for those users - it should not start at all: if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; Because it returns before the dynamic import, jQuery never loads either. Those visitors save about 32KB gzipped and get a clean static background instead. One detail I nearly shipped: the hero says "CLICK THE WATER" underneath the headline. Under reduced motion, clicking does nothing. So that line is hidden too: @media (prefers-reduced-motion: reduce) { .motion-hint { display: none; } } Telling someone to click something that will not respond is worse than saying nothing. Filming it without a screen recorder I needed a promo video, and screen recordings come with a cursor, compression mush, and whatever your OS decides to overlay on them. So I ported the plugin's shaders to numpy and rendered frames on the CPU. The update step is four np.roll calls: avg = 0.25 * ( np.roll(height, 1, axis=1) + np.roll(height, -1, axis=1) + np.roll(height, 1, axis=0) + np.roll(height, -1, axis=0) ) velocity += (avg - height) * 2.0 velocity *= 0.995 height += velocity And the render step reduces to a normalised gradient of the height field, which you use to offset the background lookup. The first output was wrong in a way I did not expect: the ripples were ovals. I had made the simulation grid match the output aspect - 512×288 for a 16:9 hero, which felt tidy. But waves propagate one grid cell per step in both axes, and if your cells are not square in screen space, circles come out stretched. The plugin does not do that. It runs a square grid and normalises by the element's longest side: var longestSide = Math.max(elWidth, elHeight); radius = radius / longestSide; Switch to a 512×512 grid covering longestSide² in screen space, display only the middle band, and the circles come back. Second surprise: the ripples spread far too slowly. The browser steps the simulation once per animation frame, roughly 60 times a second. I was stepping once per rendered frame at 22fps. Three steps per frame and the timing matched the real thing. Was it worth it? For a hero that most visitors will look at for four seconds - probably not, on any rational accounting. But every one of those four bugs was a thing I now recognise on sight, and three of them had nothing to do with water. 100vh on mobile, stale values in a batched handler, and a preload paying for a page that never uses it are all going to happen to me again. The whole thing is packaged up as a template if you want to poke at the source: ondaris-template.vercel.app Top comments (0)
Comments
No comments yet. Start the discussion.