Shaders & Backgroundsv0.2.0
Liquid Ripple
Pointer movement and clicks ripple across a surface.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/liquid-ripple.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/color.tsexport type Vec3 = [r: number, g: number, b: number]; /** * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels. * Anything else falls back to black, so a typo never crashes a shader. */ export function hexToRgb(hex: string): Vec3 { let value = hex.trim().replace(/^#/, ""); if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join(""); if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0]; const n = Number.parseInt(value.slice(0, 6), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function hexToVec3(hex: string): Vec3 { const [r, g, b] = hexToRgb(hex); return [r / 255, g / 255, b / 255]; } /** * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result * instead of calling this every frame. Browser only; unreadable values fall back to black. */ export function cssColorToRgb(color: string, element: Element): Vec3 { const value = color.trim(); if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value); // Children of a canvas still get computed styles, but a regular parent is the safer host. const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element; const probe = document.createElement("span"); probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden"; probe.style.color = value; host.appendChild(probe); const computed = getComputedStyle(probe).color; probe.remove(); // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes. const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!ctx) return [0, 0, 0]; ctx.fillStyle = "#000"; ctx.fillStyle = computed; ctx.fillRect(0, 0, 1, 1); const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; return [r, g, b]; } /** * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme. * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe. */ export function observeTheme(onChange: () => void): () => void { const observer = new MutationObserver(onChange); const options = { attributes: true, attributeFilter: ["class", "data-theme"] }; observer.observe(document.documentElement, options); if (document.body) observer.observe(document.body, options); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); scheme.addEventListener("change", onChange); return () => { observer.disconnect(); scheme.removeEventListener("change", onChange); }; } /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function cssColorToVec3(color: string, element: Element): Vec3 { const [r, g, b] = cssColorToRgb(color, element); return [r / 255, g / 255, b / 255]; } const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */ export function rgbToOklch([r, g, b]: Vec3): Vec3 { const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255)); const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; return [L, Math.hypot(A, B), Math.atan2(B, A)]; } /** OKLCH back to 0–255 sRGB, clamped into gamut. */ export function oklchToRgb([L, C, h]: Vec3): Vec3 { const A = C * Math.cos(h); const B = C * Math.sin(h); const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3; const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3; const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3; const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s]; return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3; } /** * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep * their chroma instead of turning muddy the way an RGB mix does. */ export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 { const [L1, C1, h1] = rgbToOklch(a); const [L2, C2, h2] = rgbToOklch(b); // A near-gray end has no meaningful hue: borrow the other end's. const ha = C1 < 0.02 ? h2 : h1; const hb = C2 < 0.02 ? h1 : h2; let dh = hb - ha; if (dh > Math.PI) dh -= Math.PI * 2; if (dh < -Math.PI) dh += Math.PI * 2; return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]); }lib/webgl/noise.glsl.ts/** * Shared GLSL ES 3.00 snippets. Concatenate the ones a shader needs before its main(): * * const fragment = `${GLSL_HASH}${GLSL_SIMPLEX}${GLSL_FBM} void main() { … }`; * * GLSL_SIMPLEX needs GLSL_HASH, and GLSL_FBM needs GLSL_SIMPLEX. */ /** Sine-free hashes: stable across GPUs, no visible banding. */ export const GLSL_HASH = /* glsl */ ` float hash12(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); } vec2 hash22(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.xx + p3.yz) * p3.zy); } `; /** 2D simplex noise in roughly [-1, 1]. */ export const GLSL_SIMPLEX = /* glsl */ ` vec2 simplexGradient(vec2 cell) { float angle = hash12(cell) * 6.28318530718; return vec2(cos(angle), sin(angle)); } float snoise(vec2 p) { const float F2 = 0.36602540378; // (sqrt(3) - 1) / 2 const float G2 = 0.21132486540; // (3 - sqrt(3)) / 6 vec2 cell = floor(p + (p.x + p.y) * F2); vec2 a = p - cell + (cell.x + cell.y) * G2; vec2 step1 = a.x > a.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0); vec2 b = a - step1 + G2; vec2 c = a - 1.0 + 2.0 * G2; vec3 falloff = max(0.5 - vec3(dot(a, a), dot(b, b), dot(c, c)), 0.0); falloff *= falloff; falloff *= falloff; vec3 contribution = vec3( dot(a, simplexGradient(cell)), dot(b, simplexGradient(cell + step1)), dot(c, simplexGradient(cell + 1.0)) ); return 70.0 * dot(falloff, contribution); } `; /** Fractal Brownian motion over simplex noise; octaves is clamped to 1–8. Roughly [-1, 1]. */ export const GLSL_FBM = /* glsl */ ` float fbm(vec2 p, int octaves) { const mat2 rotate = mat2(0.8, 0.6, -0.6, 0.8); float sum = 0.0; float amplitude = 0.5; for (int i = 0; i < 8; i++) { if (i >= octaves) break; sum += amplitude * snoise(p); p = rotate * p * 2.03 + 17.1; amplitude *= 0.5; } return sum; } `; /** Ordered (Bayer) dither thresholds in [0, 1) for 2×2, 4×4 and 8×8 matrices. Pass pixel coordinates. */ export const GLSL_BAYER = /* glsl */ ` float bayer2(vec2 a) { a = floor(a); return fract(a.x * 0.5 + a.y * a.y * 0.75); } float bayer4(vec2 a) { return bayer2(0.5 * a) * 0.25 + bayer2(a); } float bayer8(vec2 a) { return bayer4(0.5 * a) * 0.25 + bayer2(a); } `; /** Hash, simplex and fBm together, in dependency order. */ export const GLSL_NOISE = GLSL_HASH + GLSL_SIMPLEX + GLSL_FBM;lib/webgl/use-shader-canvas.tsimport { useEffect, useRef, useState } from "react"; /** Numbers, vectors (arrays) and, for sampler2D uniforms, an image, canvas or video element. */ export type UniformValue = number | boolean | readonly number[] | Float32Array | TexImageSource; const isTextureSource = (value: UniformValue): value is TexImageSource => typeof value === "object" && value !== null && !Array.isArray(value) && !ArrayBuffer.isView(value); /** * - `idle`: not started yet (always the value on the server and the first client render) * - `running`: animating * - `static`: a single frame is on screen (reduced motion, paused, offscreen or tab hidden) * - `fallback`: WebGL2 is unavailable, the shader failed to compile or the context was lost */ export type ShaderStatus = "idle" | "running" | "static" | "fallback"; export interface UseShaderCanvasOptions { /** * Fragment shader source. Unless it starts with `#version`, the hook prepends a header with * `precision highp float`, the built-in uniforms `u_time` (seconds), `u_resolution` (pixels), * `u_mouse` (0–1, y up) and `u_pointer` (0–1, eases to 1 while the pointer is inside), * `in vec2 v_uv` and `out vec4 fragColor`. Output premultiplied alpha. */ fragmentShader: string; /** * Custom uniforms by name. Read every frame, so updating them never restarts the shader. Pass a * function to compute them per frame from refs or other values that change without re-rendering. */ uniforms?: Record<string, UniformValue> | (() => Record<string, UniformValue>); /** Clock multiplier. @default 1 */ speed?: number; /** Freezes the clock and keeps the current frame on screen. @default false */ paused?: boolean; /** Fraction of the display resolution to render at, clamped to 0.5–1. @default 1 */ renderScale?: number; /** Highest device pixel ratio used. @default 1.5 */ maxDpr?: number; /** Track the pointer over the container into `u_mouse` and `u_pointer`. @default false */ pointer?: boolean; /** Clock value, in seconds, of the single frame drawn under reduced motion. @default 6 */ staticTime?: number; /** * Render even on software WebGL (SwiftShader, llvmpipe), where compiling and every frame run on * the CPU and block the main thread. By default those renderers get the fallback. @default false */ allowSoftware?: boolean; } const VERTEX_SHADER = /* glsl */ `#version 300 es out vec2 v_uv; void main() { vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); v_uv = p; gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); }`; const FRAGMENT_HEADER = /* glsl */ `#version 300 es precision highp float; uniform float u_time; uniform vec2 u_resolution; uniform vec2 u_mouse; uniform float u_pointer; in vec2 v_uv; out vec4 fragColor; `; interface UniformSlot { location: WebGLUniformLocation; type: number; } const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); function compile(gl: WebGL2RenderingContext, type: number, source: string) { const shader = gl.createShader(type); if (!shader) return null; gl.shaderSource(shader, source); gl.compileShader(shader); if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader; if (process.env.NODE_ENV !== "production") console.error(`useShaderCanvas: shader failed to compile\n${gl.getShaderInfoLog(shader)}`); gl.deleteShader(shader); return null; } function link(gl: WebGL2RenderingContext, fragmentSource: string) { const source = fragmentSource.trimStart().startsWith("#version") ? fragmentSource.trimStart() : FRAGMENT_HEADER + fragmentSource; const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER); const fragment = compile(gl, gl.FRAGMENT_SHADER, source); const program = vertex && fragment ? gl.createProgram() : null; if (program && vertex && fragment) { gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program); gl.detachShader(program, vertex); gl.detachShader(program, fragment); } if (vertex) gl.deleteShader(vertex); if (fragment) gl.deleteShader(fragment); if (!program) return null; if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program; if (process.env.NODE_ENV !== "production") console.error(`useShaderCanvas: program failed to link\n${gl.getProgramInfoLog(program)}`); gl.deleteProgram(program); return null; } function collectUniforms(gl: WebGL2RenderingContext, program: WebGLProgram) { const slots = new Map<string, UniformSlot>(); const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number; for (let i = 0; i < count; i++) { const info = gl.getActiveUniform(program, i); const location = info && gl.getUniformLocation(program, info.name); if (info && location) slots.set(info.name.replace(/\[0\]$/, ""), { location, type: info.type }); } return slots; } function setUniform(gl: WebGL2RenderingContext, { location, type }: UniformSlot, value: Exclude<UniformValue, TexImageSource>) { const isInt = type === gl.INT || type === gl.BOOL; if (typeof value === "boolean" || typeof value === "number") { const n = Number(value); if (isInt) gl.uniform1i(location, n); else gl.uniform1f(location, n); return; } const list = value as number[] | Float32Array; if (isInt) gl.uniform1iv(location, Int32Array.from(list)); else if (type === gl.FLOAT_VEC2) gl.uniform2fv(location, list); else if (type === gl.FLOAT_VEC3) gl.uniform3fv(location, list); else if (type === gl.FLOAT_VEC4) gl.uniform4fv(location, list); else gl.uniform1fv(location, list); } /** * Renders a fragment shader with WebGL2 on a canvas the hook creates inside the element you * attach `ref` to (give that element a size and `position: relative`, `absolute` or `fixed`). * * One fullscreen triangle, no three.js. The canvas follows the element's size, caps the pixel * ratio, pauses offscreen and in hidden tabs, draws a single static frame under reduced motion * (software WebGL such as SwiftShader gets the fallback unless allowSoftware is set), * recovers from context loss and releases the GPU context on unmount. Uniform values can be * numbers, arrays (vec2–vec4 or float arrays) or, for sampler2D uniforms, an image, canvas or * video element (uploaded once it has pixels; pass a new element or re-render to refresh). When `status` is * `fallback` (or before the first frame), whatever you render behind the canvas shows through. */ export function useShaderCanvas<T extends HTMLElement = HTMLDivElement>(options: UseShaderCanvasOptions) { const ref = useRef<T>(null); const [status, setStatus] = useState<ShaderStatus>("idle"); const latest = useRef(options); const wake = useRef<() => void>(() => {}); const time = useRef(0); // Keep the render loop reading fresh props, and let it react to paused/uniform changes. useEffect(() => { latest.current = options; wake.current(); }); const { fragmentShader } = options; useEffect(() => { const host = ref.current; if (!host) return; const canvas = document.createElement("canvas"); canvas.setAttribute("aria-hidden", "true"); Object.assign(canvas.style, { position: "absolute", inset: "0", width: "100%", height: "100%", display: "block", pointerEvents: "none", opacity: "0", transition: "opacity 500ms ease-out", }); host.appendChild(canvas); const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const mouse = [0.5, 0.5]; const mouseTarget = [0.5, 0.5]; let gl: WebGL2RenderingContext | null = null; let program: WebGLProgram | null = null; let uniforms = new Map<string, UniformSlot>(); const textures = new Map<string, { texture: WebGLTexture; unit: number; source: TexImageSource | null; ready: boolean }>(); let reported: ShaderStatus = "idle"; let raf = 0; let lastFrame = 0; let clock = 0; let pointer = 0; let pointerTarget = 0; let inView = false; let pageVisible = document.visibilityState === "visible"; let needsResize = true; let drawn = false; let lost = false; let software = false; const report = (next: ShaderStatus) => { if (next === reported) return; reported = next; setStatus(next); }; const build = () => { gl ??= canvas.getContext("webgl2", { alpha: true, antialias: false, depth: false, stencil: false, premultipliedAlpha: true, powerPreference: "low-power", }); if (gl) { const info = gl.getExtension("WEBGL_debug_renderer_info"); const renderer = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER)); software = /swiftshader|llvmpipe|softpipe|software|basic render/i.test(renderer); } // On software WebGL even compiling blocks the main thread, so use the CSS fallback instead. program = gl && !(software && !latest.current.allowSoftware) ? link(gl, fragmentShader) : null; uniforms = gl && program ? collectUniforms(gl, program) : new Map(); textures.clear(); needsResize = true; return program !== null; }; const resize = () => { if (!gl) return; const { renderScale = 1, maxDpr = 1.5 } = latest.current; const scale = Math.min(window.devicePixelRatio || 1, maxDpr) * clamp(renderScale, 0.5, 1); const width = Math.max(1, Math.round(host.clientWidth * scale)); const height = Math.max(1, Math.round(host.clientHeight * scale)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } gl.viewport(0, 0, width, height); needsResize = false; }; const draw = (time: number) => { if (!gl || !program || lost) return; if (needsResize) resize(); gl.useProgram(program); const set = (name: string, value: UniformValue) => { const slot = uniforms.get(name); if (!slot || !gl) return; if (isTextureSource(value)) bindTexture(name, slot, value); else setUniform(gl, slot, value); }; set("u_time", time); set("u_resolution", [canvas.width, canvas.height]); set("u_mouse", mouse); set("u_pointer", pointer); const source = latest.current.uniforms; const custom = typeof source === "function" ? source() : source; if (custom) for (const name in custom) set(name, custom[name]); gl.drawArrays(gl.TRIANGLES, 0, 3); if (!drawn) { drawn = true; canvas.style.opacity = "1"; } }; // Textures upload once per source (and every frame for video), as soon as the source has pixels. const bindTexture = (name: string, slot: UniformSlot, source: TexImageSource) => { if (!gl || slot.type !== gl.SAMPLER_2D) return; let entry = textures.get(name); if (!entry) { const texture = gl.createTexture(); if (!texture) return; entry = { texture, unit: textures.size, source: null, ready: false }; textures.set(name, entry); } gl.activeTexture(gl.TEXTURE0 + entry.unit); gl.bindTexture(gl.TEXTURE_2D, entry.texture); const hasPixels = typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement ? source.complete && source.naturalWidth > 0 : typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement ? source.readyState >= 2 : true; const isVideo = typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement; if (hasPixels && (entry.source !== source || !entry.ready || isVideo)) { gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); entry.source = source; entry.ready = true; } gl.uniform1i(slot.location, entry.unit); }; const still = () => reducedMotion.matches; const shouldRun = () => !lost && program !== null && inView && pageVisible && !latest.current.paused && !still(); const frame = (now: number) => { raf = 0; if (!shouldRun()) return settle(); const dt = lastFrame ? Math.min((now - lastFrame) / 1000, 0.1) : 0; lastFrame = now; clock += dt * (latest.current.speed ?? 1); const ease = 1 - Math.exp(-dt * 6); mouse[0] += (mouseTarget[0] - mouse[0]) * ease; mouse[1] += (mouseTarget[1] - mouse[1]) * ease; pointer += (pointerTarget - pointer) * ease; draw(clock); time.current = clock; report("running"); raf = requestAnimationFrame(frame); }; // Not animating: keep one up-to-date frame on screen. const settle = () => { lastFrame = 0; if (lost || !program) return; time.current = still() ? (latest.current.staticTime ?? 6) : clock; draw(time.current); report("static"); }; const update = () => { if (shouldRun()) { if (!raf) raf = requestAnimationFrame(frame); return; } if (raf) cancelAnimationFrame(raf); raf = 0; settle(); }; const intersection = new IntersectionObserver( ([entry]) => { inView = entry.isIntersecting; update(); }, { rootMargin: "120px" }, ); const resizeObserver = new ResizeObserver(() => { needsResize = true; if (!raf) settle(); }); const onVisibility = () => { pageVisible = document.visibilityState === "visible"; update(); }; const onPointerMove = (event: PointerEvent) => { const rect = host.getBoundingClientRect(); const x = (event.clientX - rect.left) / rect.width; const y = 1 - (event.clientY - rect.top) / rect.height; const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1; if (inside) { mouseTarget[0] = x; mouseTarget[1] = y; } pointerTarget = inside ? 1 : 0; }; const onPointerLeave = () => { pointerTarget = 0; }; const onContextLost = (event: Event) => { event.preventDefault(); lost = true; if (raf) cancelAnimationFrame(raf); raf = 0; report("fallback"); }; const onContextRestored = () => { lost = false; if (build()) update(); else report("fallback"); }; const trackPointer = latest.current.pointer === true; // Start on the next frame: keeps setup out of the effect body and off the hydration task. const start = requestAnimationFrame(() => { if (!build()) return report("fallback"); canvas.addEventListener("webglcontextlost", onContextLost); canvas.addEventListener("webglcontextrestored", onContextRestored); document.addEventListener("visibilitychange", onVisibility); reducedMotion.addEventListener("change", update); if (trackPointer) { window.addEventListener("pointermove", onPointerMove, { passive: true }); document.documentElement.addEventListener("pointerleave", onPointerLeave); } intersection.observe(host); resizeObserver.observe(host); wake.current = update; }); return () => { cancelAnimationFrame(start); if (raf) cancelAnimationFrame(raf); wake.current = () => {}; intersection.disconnect(); resizeObserver.disconnect(); canvas.removeEventListener("webglcontextlost", onContextLost); canvas.removeEventListener("webglcontextrestored", onContextRestored); document.removeEventListener("visibilitychange", onVisibility); reducedMotion.removeEventListener("change", update); window.removeEventListener("pointermove", onPointerMove); document.documentElement.removeEventListener("pointerleave", onPointerLeave); if (gl) { textures.forEach((entry) => gl?.deleteTexture(entry.texture)); if (program) gl.deleteProgram(program); gl.getExtension("WEBGL_lose_context")?.loseContext(); } canvas.remove(); }; }, [fragmentShader]); /** `time` holds the shader clock (seconds, as passed to u_time), e.g. to timestamp clicks. */ return { ref, status, time }; }Copy the source
components/ui/liquid-ripple/liquid-ripple.tsx"use client"; import { useEffect, useRef, type ComponentProps, type PointerEvent, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import { hexToVec3 } from "@/lib/color"; import { GLSL_NOISE } from "@/lib/webgl/noise.glsl"; import { useShaderCanvas } from "@/lib/webgl/use-shader-canvas"; export interface LiquidRippleProps extends Omit<ComponentProps<"div">, "children"> { /** Content rendered above the surface. */ children?: ReactNode; /** Water tint. @default "#BFDBFE" */ color?: string; /** Surface base color. @default "#F8FAFF" */ backgroundColor?: string; /** Ripple strength multiplier. @default 1 */ strength?: number; /** Pointer movement and clicks create ripples. @default true */ interactive?: boolean; /** Occasional ripples on their own, so the surface feels alive. @default true */ autoRipple?: boolean; /** Animation speed multiplier. @default 1 */ speed?: number; /** Freeze on the current frame. @default false */ paused?: boolean; } const MAX_RIPPLES = 8; const FRAGMENT = /* glsl */ `${GLSL_NOISE} uniform vec4 u_ripples[8]; uniform vec3 u_tint; uniform vec3 u_bg; void main() { vec2 uv = gl_FragCoord.xy / u_resolution; float aspect = u_resolution.x / u_resolution.y; float h = 0.0; vec2 grad = vec2(0.0); for (int i = 0; i < 8; i++) { vec4 r = u_ripples[i]; float age = u_time - r.z; if (r.w <= 0.0 || age < 0.0 || age > 5.0) continue; vec2 d = (uv - r.xy) * vec2(aspect, 1.0); float dist = length(d); float front = age * 0.32; float ring = exp(-pow((dist - front) * 9.0, 2.0)); float wave = sin((dist - front) * 46.0) * ring * exp(-age * 0.9) * r.w; h += wave; grad += (d / max(dist, 1e-4)) * wave; } vec2 suv = uv + grad * 0.012; float caustic = fbm(suv * vec2(aspect, 1.0) * 3.2 + u_time * 0.04, 4) * 0.5 + 0.5; vec3 col = mix(u_bg, u_tint, 0.18 + 0.42 * smoothstep(0.35, 0.85, caustic)); col += clamp(h, 0.0, 1.0) * 0.28; col -= clamp(-h, 0.0, 1.0) * 0.12 * (1.0 - u_tint); fragColor = vec4(col, 1.0); }`; // Deterministic positions for automatic ripples. const AUTO = [ [0.28, 0.62], [0.72, 0.38], [0.5, 0.72], [0.18, 0.3], [0.82, 0.7], [0.42, 0.42], ]; export function LiquidRipple({ children, color = "#BFDBFE", backgroundColor = "#F8FAFF", strength = 1, interactive = true, autoRipple = true, speed = 1, paused = false, className, style, ...props }: LiquidRippleProps) { // Ripples live in a ring buffer the shader reads every frame: x, y, start time, strength. const ripples = useRef(new Float32Array(MAX_RIPPLES * 4)); const next = useRef(0); const lastMove = useRef({ t: 0, x: -1, y: -1 }); const tint = hexToVec3(color); const background = hexToVec3(backgroundColor); const { ref, time } = useShaderCanvas<HTMLDivElement>({ fragmentShader: FRAGMENT, renderScale: 0.7, speed, paused, uniforms: () => ({ u_ripples: ripples.current, u_tint: tint, u_bg: background }), }); const add = (x: number, y: number, power: number) => { const slot = (next.current % MAX_RIPPLES) * 4; ripples.current.set([x, y, time.current, power * strength], slot); next.current += 1; }; useEffect(() => { if (!autoRipple || paused || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; let i = 0; const timer = window.setInterval(() => { const [x, y] = AUTO[i++ % AUTO.length]; add(x, y, 0.7); }, 2600); return () => window.clearInterval(timer); // eslint-disable-next-line react-hooks/exhaustive-deps -- add only reads refs }, [autoRipple, paused]); const point = (event: PointerEvent<HTMLDivElement>) => { const rect = event.currentTarget.getBoundingClientRect(); return [(event.clientX - rect.left) / rect.width, 1 - (event.clientY - rect.top) / rect.height] as const; }; return ( <div className={cn("relative isolate overflow-hidden", className)} style={{ background: `radial-gradient(120% 90% at 50% 40%, color-mix(in oklab, ${color} 45%, ${backgroundColor}), ${backgroundColor})`, ...style }} onPointerMove={(event) => { if (!interactive) return; const [x, y] = point(event); const now = performance.now(); const moved = Math.hypot(x - lastMove.current.x, y - lastMove.current.y); if (now - lastMove.current.t < 140 || moved < 0.03) return; lastMove.current = { t: now, x, y }; add(x, y, 0.35); }} onPointerDown={(event) => { if (!interactive) return; const [x, y] = point(event); add(x, y, 1); }} {...props} > <div ref={ref} aria-hidden="true" className="absolute inset-0 -z-10" /> {children} </div> ); }
Usage
import { LiquidRipple } from "@/components/ui/liquid-ripple/liquid-ripple";
<LiquidRipple className="h-80">
<YourContent />
</LiquidRipple>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | – | Content rendered above the surface. |
color | string | "#BFDBFE" | Water tint. |
backgroundColor | string | "#F8FAFF" | Surface base color. |
strength | number | 1 | Ripple strength multiplier. |
interactive | boolean | true | Pointer movement and clicks create ripples. |
autoRipple | boolean | true | Occasional ripples on their own, so the surface feels alive. |
speed | number | 1 | Animation speed multiplier. |
paused | boolean | false | Freeze on the current frame. |
Also accepts every native <div> attribute, including ref and className.
- Up to eight ripples live in a ring buffer the shader reads every frame (position, start time, strength). Pointer moves add small ripples at most every 140ms, clicks add a strong one, and each wave front travels outward while it decays.
- Built on useShaderCanvas: it pauses offscreen and in hidden tabs, caps the pixel ratio at 1.5, recovers from context loss and releases the GPU on unmount.
Accessibility
- Ripples are decorative. Children stay fully interactive, and clicking anywhere also creates a ripple.
- With reduced motion the shader draws a single still frame. Without WebGL2, or on software rendering with no GPU, a CSS fallback in the same colors is shown.