All loaders

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

Download HTML

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

NameTypeDescription
stateAgentState idle · thinking · streaming · tool-calling · waiting · done · error
progressnumber (0 to 1) Determinate progress from 0 to 1.
sizeCSS lengthOverall square size.
--orbux-widthCSS lengthOptional inline-size override.
--orbux-heightCSS lengthOptional block-size override.
speednumberAnimation speed multiplier (1 = default).
labelstringAccessible label (defaults per state).
pausedbooleanFreeze 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.