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
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
| Name | Type | Description |
|---|---|---|
state | AgentState | idle · thinking · streaming · tool-calling · waiting · done · error |
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.