WebGL · 6.3 KB gzip
Pulse Orb
A fluid orb rendered with a raw WebGL fragment shader and state-driven color and motion.
orbglowfluidherowebglpremium
Interactive preview
Usage
Packages are @vikast908/core and
@vikast908/loaders. Prefer 0.1.1+
(0.1.0 loaders is broken on npm).
npm install @vikast908/loaders@0.1.1 @vikast908/core@0.1.1
import '@vikast908/loaders/pulse-orb'; <orbux-pulse-orb state="thinking" size="160px"></orbux-pulse-orb>
Browser-only alternative: Download HTML (self-contained) or load the hosted ESM bundle:
<script type="module" src="http://localhost:4321/orbux/pulse-orb.js"></script> <orbux-pulse-orb state="thinking" size="160px"></orbux-pulse-orb>
Component source pulse-orb.ts
import { type AgentState, lerp, type OrbuxFrameInfo, ShaderElement } from '@vikast908/core';
/**
* Pulse Orb: a glowing, fluid, GPU-rendered orb. The signature OrbUX visual.
*
* Self-contained raw WebGL (no third-party library): a single full-screen quad
* driven by a domain-warped fbm noise shader. State controls color, energy and
* spin; `progress` (0..1) draws an optional ring. Falls back to an animated CSS
* gradient if WebGL is unavailable.
*/
const FRAG = /* glsl */ `
precision highp float;
uniform vec2 uRes;
uniform float uTime;
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform float uActivity; // 0..1: how energetic the surface is
uniform float uState;
uniform float uSpin; // rotation speed
uniform float uProgress; // 0..1, or < 0 to hide the ring
uniform vec3 uProgressColor;
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
float a = hash(i), b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0)), d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; }
return v;
}
mat2 rot(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }
void main() {
vec2 uv = (gl_FragCoord.xy * 2.0 - uRes) / min(uRes.x, uRes.y);
float r = length(uv);
float t = uTime;
// domain-warped noise for a fluid surface
vec2 q = uv * rot(t * uSpin);
float warp = fbm(q * 1.5 + t * 0.2);
float n = fbm(q * 2.5 + vec2(t * 0.6, -t * 0.4) + warp * (0.6 + uActivity));
float edge = 0.86;
float body = smoothstep(edge, edge - 0.30, r);
float glow = smoothstep(1.30, 0.0, r) * (0.35 + 0.35 * uActivity);
float shade = 0.5 + 0.55 * n + 0.25 * uActivity * sin(t * 3.0 + n * 6.2831);
vec3 col = mix(uColorA, uColorB, clamp(shade, 0.0, 1.0));
// bright rim
float rim = smoothstep(edge, edge - 0.04, r) - smoothstep(edge - 0.04, edge - 0.22, r);
col += rim * 0.45;
vec3 outCol = col * body + mix(uColorA, uColorB, 0.5) * glow;
float alpha = max(body, glow);
if (uState > 3.5 && uState < 4.5) alpha *= smoothstep(0.18, 0.28, r);
if (uState > 2.5 && uState < 3.5) alpha *= 0.78 + 0.22 * step(0.3, abs(sin(atan(uv.y, uv.x) * 4.0)));
// optional progress ring, composited independently of the orb body
if (uProgress >= 0.0) {
float a01 = mod(atan(uv.x, uv.y) + 6.2831853, 6.2831853) / 6.2831853;
float ring = smoothstep(0.025, 0.0, abs(r - 0.95));
float fill = ring * step(a01, uProgress);
outCol = mix(outCol, uProgressColor, fill * 0.9);
alpha = max(alpha, fill);
}
gl_FragColor = vec4(outCol, alpha);
}
`;
interface Visual {
activity: number;
spin: number;
colorA: [number, number, number];
colorB: [number, number, number];
}
const STATE_VISUALS: Record<AgentState, Visual> = {
idle: { activity: 0.15, spin: 0.05, colorA: [0.2, 0.28, 0.45], colorB: [0.36, 0.46, 0.72] },
thinking: { activity: 0.5, spin: 0.15, colorA: [0.39, 0.4, 0.95], colorB: [0.66, 0.33, 0.97] },
streaming: { activity: 0.95, spin: 0.28, colorA: [0.32, 0.6, 0.98], colorB: [0.55, 0.9, 1.0] },
'tool-calling': {
activity: 0.75,
spin: 0.42,
colorA: [0.06, 0.7, 0.6],
colorB: [0.2, 0.9, 0.75],
},
waiting: { activity: 0.2, spin: 0.06, colorA: [0.3, 0.34, 0.5], colorB: [0.5, 0.52, 0.72] },
done: { activity: 0.25, spin: 0.1, colorA: [0.13, 0.7, 0.4], colorB: [0.4, 0.9, 0.55] },
error: { activity: 0.55, spin: 0.02, colorA: [0.85, 0.2, 0.25], colorB: [0.95, 0.5, 0.32] },
};
export class OrbuxPulseOrb extends ShaderElement {
#cur: Visual = {
activity: 0.5,
spin: 0.15,
colorA: [0.39, 0.4, 0.95],
colorB: [0.66, 0.33, 0.97],
};
protected fragmentSource(): string {
return FRAG;
}
protected override fallbackBackground(): string {
return 'background:radial-gradient(circle at 35% 30%,var(--orbux-color-2,#a855f7),var(--orbux-color,#6366f1) 70%);';
}
protected override timeScale(): number {
return 0.5 + this.#cur.activity;
}
protected paint(gl: WebGLRenderingContext, info: OrbuxFrameInfo): void {
const target = STATE_VISUALS[info.state];
let colorA = target.colorA;
let colorB = target.colorB;
if (info.state === 'done') {
colorA = this.cssColorRGB('--orbux-color-success', target.colorA);
colorB = target.colorB;
} else if (info.state === 'error') {
colorA = this.cssColorRGB('--orbux-color-error', target.colorA);
colorB = target.colorB;
} else {
colorA = this.cssColorRGB('--orbux-color', target.colorA);
colorB = this.cssColorRGB('--orbux-color-2', target.colorB);
}
const rate = info.state === 'done' || info.state === 'error' ? 18 : 4;
const k = info.dt === 0 ? 1 : 1 - Math.exp(-rate * info.dt);
this.#cur.activity = lerp(this.#cur.activity, target.activity, k);
this.#cur.spin = lerp(this.#cur.spin, target.spin, k);
for (let i = 0; i < 3; i++) {
this.#cur.colorA[i] = lerp(this.#cur.colorA[i] ?? 0, colorA[i] ?? 0, k);
this.#cur.colorB[i] = lerp(this.#cur.colorB[i] ?? 0, colorB[i] ?? 0, k);
}
gl.uniform3fv(this.uniform('uColorA'), this.#cur.colorA);
gl.uniform3fv(this.uniform('uColorB'), this.#cur.colorB);
gl.uniform1f(this.uniform('uActivity'), this.#cur.activity);
gl.uniform1f(this.uniform('uSpin'), this.#cur.spin);
}
}
if (typeof customElements !== 'undefined' && !customElements.get('orbux-pulse-orb')) {
customElements.define('orbux-pulse-orb', OrbuxPulseOrb);
}
declare global {
interface HTMLElementTagNameMap {
'orbux-pulse-orb': OrbuxPulseOrb;
}
}
Depends on @vikast908/core. Paste the source, add the base
element, and set state from your agent code.
Attributes & properties
| Name | Type | Description |
|---|---|---|
state | AgentState | idle · thinking · streaming · tool-calling · waiting · done · error |
progress | number (0 to 1) | Determinate progress from 0 to 1. |
size | CSS length | Overall square size. |
--orbux-width | CSS length | Optional inline-size override. |
--orbux-height | CSS length | Optional block-size override. |
speed | number | Animation speed multiplier (1 = default). |
label | string | Accessible label (defaults per state). |
paused | boolean | Freeze the animation. |
Respects prefers-reduced-motion, exposes
status/progressbar semantics with a live label, and auto-pauses (sets
data-paused) when offscreen or the tab is hidden.