336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
"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<HTMLDivElement>(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 (
|
|
<div
|
|
ref={wrapRef}
|
|
className={`fluid fluid--${variant} ${className ?? ""}`}
|
|
aria-hidden="true"
|
|
>
|
|
{/* static gradient fallback — always painted underneath; the only thing
|
|
shown under reduced-motion / no-WebGL */}
|
|
<div className="fluid__fallback" />
|
|
</div>
|
|
);
|
|
}
|