Shaders & Backgroundsv0.2.0
Glow Orb
A noisy, pulsing orb for AI assistants.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/glow-orb.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/glow-orb/glow-orb.tsx"use client"; import type { ComponentProps } 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 type OrbState = "idle" | "listening" | "thinking" | "speaking"; export interface GlowOrbProps extends Omit<ComponentProps<"div">, "children"> { /** Assistant state; each one moves at its own pace. @default "idle" */ state?: OrbState; /** Audio level from 0 to 1, e.g. microphone or speech volume. Swells the orb. @default 0 */ level?: number; /** Highlight color at the core. @default "#93C5FD" */ innerColor?: string; /** Body and glow color. @default "#6366F1" */ outerColor?: string; /** Accessible label; announced with the state. @default "Assistant" */ label?: string; /** Freeze on the current frame. @default false */ paused?: boolean; } const FRAGMENT = /* glsl */ `${GLSL_NOISE} uniform vec3 u_inner; uniform vec3 u_outer; uniform float u_level; uniform float u_activity; void main() { vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y); float t = u_time * u_activity; float r = length(p); float R = 0.27 + 0.018 * sin(t * 2.2) + u_level * 0.06; float n = fbm(p * 3.0 + vec2(t * 0.3, -t * 0.22), 4); float edge = R + n * 0.03; float body = smoothstep(edge, edge - 0.012, r); float swirl = fbm(p * 3.6 + n * 1.6 + vec2(t * 0.12, t * 0.07), 4) * 0.5 + 0.5; vec3 core = mix(u_outer, u_inner, clamp(smoothstep(R, 0.0, r) * 0.7 + swirl * 0.5, 0.0, 1.0)); core += vec3(1.0) * smoothstep(0.13, 0.0, length(p - vec2(-0.09, 0.1))) * 0.35; float glow = exp(-max(r - R * 0.85, 0.0) * 10.0) * (0.45 + u_level * 0.3); vec3 col = core * body + u_outer * glow * (1.0 - body); float a = clamp(body + glow * (1.0 - body), 0.0, 1.0); fragColor = vec4(col, a); }`; const ACTIVITY: Record<OrbState, number> = { idle: 0.55, listening: 1, thinking: 1.9, speaking: 1.3 }; export function GlowOrb({ state = "idle", level = 0, innerColor = "#93C5FD", outerColor = "#6366F1", label = "Assistant", paused = false, className, style, ...props }: GlowOrbProps) { const { ref } = useShaderCanvas<HTMLDivElement>({ fragmentShader: FRAGMENT, renderScale: 0.75, paused, staticTime: 3, uniforms: { u_inner: hexToVec3(innerColor), u_outer: hexToVec3(outerColor), u_level: Math.min(1, Math.max(0, level)), u_activity: ACTIVITY[state], }, }); return ( <div role="img" aria-label={`${label}, ${state}`} className={cn("relative isolate aspect-square w-48 overflow-hidden", className)} style={{ background: `radial-gradient(circle at 42% 40%, ${innerColor}, ${outerColor} 26%, color-mix(in oklab, ${outerColor} 25%, transparent) 30%, transparent 46%)`, ...style, }} {...props} > <div ref={ref} aria-hidden="true" className="absolute inset-0 -z-10" /> </div> ); }
Usage
import { GlowOrb } from "@/components/ui/glow-orb/glow-orb";
<GlowOrb state={assistantState} level={audioLevel} className="w-48" />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
state | OrbState | "idle" | Assistant state; each one moves at its own pace. |
level | number | 0 | Audio level from 0 to 1, e.g. microphone or speech volume. Swells the orb. |
innerColor | string | "#93C5FD" | Highlight color at the core. |
outerColor | string | "#6366F1" | Body and glow color. |
label | string | "Assistant" | Accessible label; announced with the state. |
paused | boolean | false | Freeze on the current frame. |
Also accepts every native <div> attribute, including ref and className.
- A noise-displaced sphere with a swirling fBm interior, a highlight and a soft halo, drawn with alpha on any background. The state sets the pace and level swells it; feed level from an AnalyserNode for real audio.
- 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 orb is an image labelled with its state ("Assistant, thinking"). Pair it with a visible or live text status for real assistants.
- 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.