"use client"; /** * FLUID BACKGROUND — a live, cursor-reactive WebGL field (ogl) that sits BEHIND * the content. A full-screen fragment shader paints a flowing iridescent * metaball / fluid mesh in the brand palette (blue -> violet -> emerald). The * field bends, glows and FLOWS toward the pointer: a spring-smoothed pointer * position warps the domain-warped noise and injects a bright, additive glow * blob that trails the cursor with momentum. * * How the cursor-reactivity works: * - We track the raw pointer in 0..1 UV space and LERP a smoothed pointer * toward it every frame (momentum => the glow trails, never snaps). * - We also track pointer *velocity* (smoothed delta) and feed it to the * shader as `uVel`, so fast flicks visibly push/stretch the fluid. * - In the shader, the metaballs and the domain-warp offset are pulled toward * `uPointer`; a soft radial glow blooms at the pointer; warp strength scales * with `uVel` so the surface "reacts" to how you move. * * Variants: * - "hero": subtle, recedes behind the headline. * - "cta": bigger, brighter, faster — the closing dramatic moment. * * Performance & resilience: * - Pauses rendering when the tab is hidden OR the canvas is offscreen (IO). * - DPR capped at 1.6 so it never tanks fill-rate / LCP. * - WebGL capability check: if no GL context, we render nothing and the CSS * gradient fallback (always painted underneath) shows through. * - prefers-reduced-motion: we never start the GL loop; the static CSS * gradient fallback stands in. * - Full cleanup on unmount (RAF, listeners, GL context lost). */ import { useEffect, useRef } from "react"; import { Renderer, Program, Mesh, Triangle, Vec2 } from "ogl"; type Variant = "hero" | "cta"; const FRAG = /* glsl */ ` precision highp float; uniform float uTime; uniform vec2 uRes; uniform vec2 uPointer; // smoothed pointer, 0..1 (y up) uniform float uVel; // smoothed pointer speed 0..~1 uniform float uIntensity; // variant intensity uniform float uReveal; // 0..1 fade-in on mount varying vec2 vUv; // brand palette const vec3 C_BLUE = vec3(0.231, 0.510, 0.965); // #3b82f6 const vec3 C_VIOLET = vec3(0.545, 0.361, 0.965); // #8b5cf6 const vec3 C_EMER = vec3(0.063, 0.725, 0.506); // #10b981 const vec3 C_BG = vec3(0.027, 0.027, 0.043); // #07070b // hash / value noise float hash(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } float noise(vec2 p){ vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); float a = hash(i); float b = hash(i + vec2(1.0, 0.0)); float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0)); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } float fbm(vec2 p){ float v = 0.0; float a = 0.5; for (int i = 0; i < 5; i++){ v += a * noise(p); p *= 2.0; a *= 0.5; } return v; } // soft metaball field — sum of inverse-distance blobs that orbit + drift float metaballs(vec2 p, vec2 ptr, float t, float speed){ float f = 0.0; for (int i = 0; i < 5; i++){ float fi = float(i); // each blob orbits on its own phase vec2 c = vec2( 0.5 + 0.34 * sin(t * speed * (0.4 + fi * 0.12) + fi * 1.7), 0.5 + 0.30 * cos(t * speed * (0.5 + fi * 0.09) + fi * 2.3) ); // pull every blob a little toward the pointer => the field "flows" to it c = mix(c, ptr, 0.18 + 0.05 * fi); float r = 0.16 + 0.05 * sin(t * 0.6 + fi); f += r * r / (dot(p - c, p - c) + 0.0009); } return f; } void main(){ vec2 uv = vUv; // correct for aspect so blobs stay round-ish float aspect = uRes.x / max(uRes.y, 1.0); vec2 p = uv; p.x *= aspect; vec2 ptr = uPointer; ptr.x *= aspect; float t = uTime; // domain warp — distortion grows toward the pointer and with pointer speed float dPtr = distance(p, ptr); float pull = exp(-dPtr * 2.4); // 1 near cursor -> 0 far float warpAmt = (0.18 + uVel * 0.9) * (0.4 + pull); vec2 q = p + warpAmt * vec2( fbm(p * 2.2 + vec2(t * 0.10, t * 0.07)), fbm(p * 2.2 + vec2(-t * 0.08, t * 0.12) + 5.2) ); // base flowing fbm field float n = fbm(q * 1.7 + vec2(t * 0.05, -t * 0.04)); // metaballs flowing toward the pointer float mb = metaballs(q, ptr, t, 0.7 + uVel * 1.2); float field = smoothstep(0.7, 1.9, mb) * 0.9 + n * 0.6; // iridescent color ramp across the field vec3 col = mix(C_BLUE, C_VIOLET, smoothstep(0.15, 0.7, field + n * 0.3)); col = mix(col, C_EMER, smoothstep(0.55, 1.05, field + 0.18 * sin(t * 0.4 + uv.x * 3.0))); // composite onto the dark canvas by the field strength float lum = smoothstep(0.05, 1.2, field); vec3 outc = mix(C_BG, col, clamp(lum, 0.0, 1.0) * uIntensity); // pointer bloom — a bright additive glow that trails the cursor float glow = exp(-dPtr * (4.5 - uVel * 1.5)) * (0.45 + uVel * 0.6); vec3 glowCol = mix(C_VIOLET, C_EMER, 0.4 + 0.4 * sin(t * 0.7)); outc += glowCol * glow * uIntensity; // subtle grain so it never bands on dark gradients float g = hash(uv * uRes.xy * 0.5 + t) - 0.5; outc += g * 0.018; // soft vignette keeps edges grounded in the canvas float vig = smoothstep(1.25, 0.25, length(uv - 0.5)); outc = mix(C_BG, outc, 0.35 + 0.65 * vig); gl_FragColor = vec4(outc * uReveal, 1.0); } `; const VERT = /* glsl */ ` attribute vec2 uv; attribute vec2 position; varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position, 0.0, 1.0); } `; export default function FluidBackground({ variant = "hero", className, }: { variant?: Variant; className?: string; }) { const wrapRef = useRef(null); useEffect(() => { const wrap = wrapRef.current; if (!wrap) return; const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduce) return; // CSS gradient fallback stands in // --- WebGL capability check --- let renderer: Renderer; try { renderer = new Renderer({ alpha: false, antialias: false, dpr: Math.min(window.devicePixelRatio || 1, 1.6), powerPreference: "high-performance", }); } catch { return; // no GL -> CSS fallback shows } const gl = renderer.gl; if (!gl) return; const canvas = gl.canvas as HTMLCanvasElement; canvas.className = "fluid__canvas"; canvas.setAttribute("aria-hidden", "true"); wrap.appendChild(canvas); const isCta = variant === "cta"; // cta is bigger/faster but slightly toned so the centred headline stays // legible against it (the readability backdrop does the rest) const intensity = isCta ? 0.9 : 0.78; const geometry = new Triangle(gl); const program = new Program(gl, { vertex: VERT, fragment: FRAG, uniforms: { uTime: { value: 0 }, uRes: { value: new Vec2(1, 1) }, uPointer: { value: new Vec2(0.5, 0.5) }, uVel: { value: 0 }, uIntensity: { value: intensity }, uReveal: { value: 0 }, }, }); const mesh = new Mesh(gl, { geometry, program }); const resize = () => { const w = wrap.clientWidth || window.innerWidth; const h = wrap.clientHeight || window.innerHeight; renderer.setSize(w, h); program.uniforms.uRes.value.set(gl.drawingBufferWidth, gl.drawingBufferHeight); }; resize(); const ro = new ResizeObserver(resize); ro.observe(wrap); // --- pointer tracking (raw -> smoothed, with velocity) --- const rawPtr = { x: 0.5, y: 0.5 }; const smoothPtr = { x: 0.5, y: 0.5 }; let vel = 0; let lastX = 0.5; let lastY = 0.5; const onPointerMove = (e: PointerEvent) => { const r = wrap.getBoundingClientRect(); rawPtr.x = (e.clientX - r.left) / r.width; // flip Y so shader-space y is up and matches screen intuition rawPtr.y = 1 - (e.clientY - r.top) / r.height; }; // listen on window so movement is tracked even over the content layer window.addEventListener("pointermove", onPointerMove, { passive: true }); // --- visibility / offscreen pausing --- let running = false; let rafId = 0; let inView = true; let pageVisible = !document.hidden; const startTime = performance.now(); let lastTick = startTime; const frame = (now: number) => { if (!running) return; const dt = Math.min((now - lastTick) / 1000, 0.05); lastTick = now; // momentum: lerp smoothed pointer toward raw const ease = 0.07; smoothPtr.x += (rawPtr.x - smoothPtr.x) * ease; smoothPtr.y += (rawPtr.y - smoothPtr.y) * ease; // velocity from smoothed delta, decayed for a soft trailing reaction const dx = smoothPtr.x - lastX; const dy = smoothPtr.y - lastY; lastX = smoothPtr.x; lastY = smoothPtr.y; const instVel = Math.min(Math.hypot(dx, dy) * 60, 1); vel += (instVel - vel) * 0.12; program.uniforms.uTime.value = (now - startTime) / 1000; program.uniforms.uPointer.value.set(smoothPtr.x, smoothPtr.y); program.uniforms.uVel.value = vel; // ease the reveal in over ~1s const rev = program.uniforms.uReveal.value as number; if (rev < 1) program.uniforms.uReveal.value = Math.min(1, rev + dt * 1.4); renderer.render({ scene: mesh }); rafId = requestAnimationFrame(frame); }; const start = () => { if (running || !inView || !pageVisible) return; running = true; lastTick = performance.now(); rafId = requestAnimationFrame(frame); }; const stop = () => { running = false; if (rafId) cancelAnimationFrame(rafId); rafId = 0; }; const io = new IntersectionObserver( ([entry]) => { inView = entry.isIntersecting; if (inView) start(); else stop(); }, { threshold: 0 } ); io.observe(wrap); const onVisibility = () => { pageVisible = !document.hidden; if (pageVisible) start(); else stop(); }; document.addEventListener("visibilitychange", onVisibility); start(); return () => { stop(); io.disconnect(); ro.disconnect(); window.removeEventListener("pointermove", onPointerMove); document.removeEventListener("visibilitychange", onVisibility); const ext = gl.getExtension("WEBGL_lose_context"); ext?.loseContext(); if (canvas.parentNode) canvas.parentNode.removeChild(canvas); }; }, [variant]); return (