Shaders & Backgroundsv0.2.0
Mesh Gradient
A slowly flowing pastel mesh in four colors.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/mesh-gradient.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/mesh-gradient/mesh-gradient.tsx"use client"; import type { ComponentProps, 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 MeshGradientProps extends Omit<ComponentProps<"div">, "children"> { /** Content rendered above the gradient. */ children?: ReactNode; /** Four mesh colors. @default ["#C7D2FE", "#FBCFE8", "#BAE6FD", "#FDE68A"] */ colors?: [string, string, string, string]; /** Flow speed multiplier. @default 1 */ speed?: number; /** Freeze on the current frame. @default false */ paused?: boolean; } const FRAGMENT = /* glsl */ `${GLSL_NOISE} uniform vec3 u_c1; uniform vec3 u_c2; uniform vec3 u_c3; uniform vec3 u_c4; void main() { vec2 uv = gl_FragCoord.xy / u_resolution; float aspect = u_resolution.x / u_resolution.y; float t = u_time * 0.12; vec2 p = uv + 0.1 * vec2(snoise(uv * 1.4 + t), snoise(uv * 1.4 + vec2(4.0, 2.0) - t)); p.x *= aspect; vec2 a = vec2((0.2 + 0.18 * sin(t * 1.3)) * aspect, 0.25 + 0.2 * cos(t * 1.1)); vec2 b = vec2((0.82 + 0.12 * cos(t * 0.9)) * aspect, 0.3 + 0.2 * sin(t * 1.7)); vec2 c = vec2((0.25 + 0.2 * cos(t * 1.5)) * aspect, 0.8 + 0.12 * sin(t * 0.8)); vec2 d = vec2((0.78 + 0.15 * sin(t * 1.2)) * aspect, 0.76 + 0.15 * cos(t * 1.4)); float wa = 1.0 / (0.03 + pow(distance(p, a), 2.2)); float wb = 1.0 / (0.03 + pow(distance(p, b), 2.2)); float wc = 1.0 / (0.03 + pow(distance(p, c), 2.2)); float wd = 1.0 / (0.03 + pow(distance(p, d), 2.2)); vec3 col = (u_c1 * wa + u_c2 * wb + u_c3 * wc + u_c4 * wd) / (wa + wb + wc + wd); fragColor = vec4(col, 1.0); }`; export function MeshGradient({ children, colors = ["#C7D2FE", "#FBCFE8", "#BAE6FD", "#FDE68A"], speed = 1, paused = false, className, style, ...props }: MeshGradientProps) { const [c1, c2, c3, c4] = colors; const { ref } = useShaderCanvas<HTMLDivElement>({ fragmentShader: FRAGMENT, renderScale: 0.5, speed, paused, uniforms: { u_c1: hexToVec3(c1), u_c2: hexToVec3(c2), u_c3: hexToVec3(c3), u_c4: hexToVec3(c4) }, }); const fallback = `radial-gradient(at 20% 25%, ${c1}, transparent 55%), radial-gradient(at 80% 30%, ${c2}, transparent 55%), radial-gradient(at 25% 80%, ${c3}, transparent 55%), radial-gradient(at 78% 76%, ${c4}, transparent 55%), ${c1}`; return ( <div className={cn("relative isolate overflow-hidden", className)} style={{ background: fallback, ...style }} {...props}> <div ref={ref} aria-hidden="true" className="absolute inset-0 -z-10" /> {children} </div> ); }
Usage
import { MeshGradient } from "@/components/ui/mesh-gradient/mesh-gradient";
<MeshGradient className="min-h-96" colors={["#C7D2FE", "#FBCFE8", "#BAE6FD", "#FDE68A"]}>
<YourContent />
</MeshGradient>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | – | Content rendered above the gradient. |
colors | [string, string, string, string] | ["#C7D2FE", "#FBCFE8", "#BAE6FD", "#FDE68A"] | Four mesh colors. |
speed | number | 1 | Flow speed multiplier. |
paused | boolean | false | Freeze on the current frame. |
Also accepts every native <div> attribute, including ref and className.
- Four control points drift on slow orbits; each pixel blends their colors by inverse distance, lightly warped by simplex noise. Renders at half resolution, since soft gradients don't need more.
- 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
- The canvas is decorative (aria-hidden); children render above it as normal content.
- 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.