All loaders

Canvas 2D · 3.8 KB gzip

Ripple Field

Concentric sonar-like ripples radiating from a pulsing core; rate, speed and thickness scale with activity.

ripplewavesconcentricsonarcanvas
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/ripple-field';

<orbux-ripple-field state="thinking"></orbux-ripple-field>

Browser-only alternative: Download HTML (self-contained) or load the hosted ESM bundle:

<script type="module" src="http://localhost:4321/orbux/ripple-field.js"></script>

<orbux-ripple-field state="thinking"></orbux-ripple-field>

Component source ripple-field.ts

import {
  type AgentState,
  Canvas2DElement,
  clamp,
  lerp,
  noise1D,
  type OrbuxFrameInfo,
} from '@vikast908/core';

const TAU = Math.PI * 2;

interface Ring {
  radius: number; // 0..1 fraction of max
  width: number;
}

interface Tune {
  rate: number; // rings per second
  speed: number; // expansion speed
  width: number; // stroke width factor
}

const STATE_TUNE: Record<AgentState, Tune> = {
  idle: { rate: 0.5, speed: 0.28, width: 1 },
  thinking: { rate: 1.4, speed: 0.5, width: 1.2 },
  streaming: { rate: 3.4, speed: 0.95, width: 1.8 },
  'tool-calling': { rate: 2.2, speed: 0.7, width: 2.6 },
  waiting: { rate: 0.7, speed: 0.32, width: 1 },
  done: { rate: 0.5, speed: 0.4, width: 2.2 },
  error: { rate: 2.8, speed: 0.8, width: 1.6 },
};

/**
 * Ripple Field: concentric sonar-like ripples radiating from a pulsing core.
 * Ring rate, speed and thickness scale with activity.
 */
export class OrbuxRippleField extends Canvas2DElement {
  #rings: Ring[] = [];
  #acc = 0;
  #cur: Tune = { ...STATE_TUNE.thinking };

  protected override setup(): void {
    this.#rings = [
      { radius: 0.18, width: this.#cur.width },
      { radius: 0.5, width: this.#cur.width },
      { radius: 0.82, width: this.#cur.width },
    ];
  }

  protected draw(info: OrbuxFrameInfo): void {
    const ctx = this.ctx;
    if (!ctx) return;
    const t = STATE_TUNE[info.state];
    const rate = info.state === 'done' || info.state === 'error' ? 18 : 3;
    const k = info.dt === 0 ? 1 : 1 - Math.exp(-rate * info.dt);
    this.#cur.rate = lerp(this.#cur.rate, t.rate, k);
    this.#cur.speed = lerp(this.#cur.speed, t.speed, k);
    this.#cur.width = lerp(this.#cur.width, t.width, k);

    const color = this.cssColor('--orbux-color', '#6366f1');
    const color2 = this.cssColor('--orbux-color-2', '#a855f7');
    const terminal =
      info.state === 'done'
        ? this.cssColor('--orbux-color-success', '#22c55e')
        : info.state === 'error'
          ? this.cssColor('--orbux-color-error', '#ef4444')
          : null;
    const ringColor = terminal ?? color;

    const cx = this.width / 2;
    const cy = this.height / 2;
    const maxR = Math.min(this.width, this.height) * 0.48;

    // spawn
    this.#acc += info.dt * this.#cur.rate * info.speed * 1.5;
    while (this.#acc >= 1) {
      this.#acc -= 1;
      this.#rings.push({ radius: 0, width: this.#cur.width });
    }

    // advance
    for (const ring of this.#rings) {
      ring.radius += info.dt * this.#cur.speed * info.speed * 1.5;
    }
    this.#rings = this.#rings.filter((r) => r.radius < 1);

    this.clear();
    ctx.lineCap = 'round';
    for (const [index, ring] of this.#rings.entries()) {
      const jitter =
        info.state === 'error' ? noise1D(index + 1, info.elapsed * 10) * maxR * 0.02 : 0;
      ctx.beginPath();
      ctx.arc(cx + jitter, cy + jitter, ring.radius * maxR, 0, TAU);
      ctx.strokeStyle = ringColor;
      ctx.globalAlpha = clamp(1 - ring.radius, 0, 1) * 0.85;
      ctx.lineWidth = Math.max(1, maxR * 0.02 * ring.width);
      ctx.stroke();
    }

    // core
    ctx.globalAlpha = 1;
    const pulse = 1 + 0.18 * Math.sin(info.elapsed * 4);
    ctx.beginPath();
    ctx.arc(cx, cy, maxR * 0.1 * pulse, 0, TAU);
    ctx.fillStyle = terminal ?? color2;
    ctx.shadowBlur = maxR * 0.12;
    ctx.shadowColor = terminal ?? color2;
    ctx.fill();
    ctx.shadowBlur = 0;
  }
}

if (typeof customElements !== 'undefined' && !customElements.get('orbux-ripple-field')) {
  customElements.define('orbux-ripple-field', OrbuxRippleField);
}

declare global {
  interface HTMLElementTagNameMap {
    'orbux-ripple-field': OrbuxRippleField;
  }
}

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
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.