Shaders & Backgroundsv0.2.0
Dot Matrix Field
A dot grid scaling with a moving noise field.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/dot-matrix-field.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/dot-matrix-field/dot-matrix-field.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 DotMatrixFieldProps extends Omit<ComponentProps<"div">, "children"> { /** Content rendered above the dots. */ children?: ReactNode; /** Distance between dot centers in canvas pixels. @default 14 */ spacing?: number; /** Dot color. @default "#09090B" */ color?: string; /** Largest dot radius as a share of the cell, 0.1–0.5. @default 0.38 */ maxSize?: number; /** Overall opacity of the dots. @default 1 */ opacity?: number; /** Dots swell near the cursor. @default true */ interactive?: boolean; /** Field speed multiplier. @default 1 */ speed?: number; /** Freeze on the current frame. @default false */ paused?: boolean; } const FRAGMENT = /* glsl */ `${GLSL_NOISE} uniform float u_spacing; uniform vec3 u_dot; uniform float u_max; uniform float u_opacity; uniform float u_interactive; void main() { vec2 cell = floor(gl_FragCoord.xy / u_spacing); vec2 local = fract(gl_FragCoord.xy / u_spacing) - 0.5; vec2 cuv = (cell + 0.5) * u_spacing / u_resolution.y; float aspect = u_resolution.x / u_resolution.y; float n = fbm(cuv * 2.0 + vec2(u_time * 0.07, u_time * 0.035), 3) * 0.5 + 0.5; n += u_pointer * u_interactive * 0.6 * smoothstep(0.3, 0.0, distance(cuv, u_mouse * vec2(aspect, 1.0))); float r = mix(0.08, u_max, smoothstep(0.25, 0.8, n)); float a = smoothstep(r, r - 1.2 * 2.0 / u_spacing, length(local)); float o = a * mix(0.22, 0.9, smoothstep(0.25, 0.85, n)) * u_opacity; fragColor = vec4(u_dot * o, o); }`; export function DotMatrixField({ children, spacing = 14, color = "#09090B", maxSize = 0.38, opacity = 1, interactive = true, speed = 1, paused = false, className, style, ...props }: DotMatrixFieldProps) { const { ref } = useShaderCanvas<HTMLDivElement>({ fragmentShader: FRAGMENT, speed, paused, pointer: interactive, uniforms: { u_spacing: Math.max(6, spacing), u_dot: hexToVec3(color), u_max: Math.min(0.5, Math.max(0.1, maxSize)), u_opacity: opacity, u_interactive: interactive ? 1 : 0, }, }); return ( <div className={cn("relative isolate overflow-hidden", className)} style={{ backgroundImage: `radial-gradient(color-mix(in oklab, ${color} 22%, transparent) 1.2px, transparent 1.7px)`, backgroundSize: `${spacing}px ${spacing}px`, ...style, }} {...props} > <div ref={ref} aria-hidden="true" className="absolute inset-0 -z-10" /> {children} </div> ); }
Usage
import { DotMatrixField } from "@/components/ui/dot-matrix-field/dot-matrix-field";
<DotMatrixField className="h-80" spacing={14} />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | – | Content rendered above the dots. |
spacing | number | 14 | Distance between dot centers in canvas pixels. |
color | string | "#09090B" | Dot color. |
maxSize | number | 0.38 | Largest dot radius as a share of the cell, 0.1–0.5. |
opacity | number | 1 | Overall opacity of the dots. |
interactive | boolean | true | Dots swell near the cursor. |
speed | number | 1 | Field speed multiplier. |
paused | boolean | false | Freeze on the current frame. |
Also accepts every native <div> attribute, including ref and className.
- Each dot samples a drifting fBm field at its cell center, which sets its radius and opacity. Near the cursor, dots get an extra boost. The background is transparent, so the field sits on any surface.
- 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.