Canvas 2D · 3.8 KB gzip
Particle Swarm
A cloud of glowing particles orbiting a core: they speed up, tighten, converge on done, and scatter on error.
particlescanvasorbitgenerativeenergetic
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/particle-swarm'; <orbux-particle-swarm state="thinking"></orbux-particle-swarm>
Browser-only alternative: Download HTML (self-contained) or load the hosted ESM bundle:
<script type="module" src="http://localhost:4321/orbux/particle-swarm.js"></script> <orbux-particle-swarm state="thinking"></orbux-particle-swarm>
Component source particle-swarm.ts
import { type AgentState, Canvas2DElement, clamp, lerp, type OrbuxFrameInfo } from '@vikast908/core';
const TAU = Math.PI * 2;
interface Particle {
angle: number;
radius: number; // 0..1 fraction of base radius
speed: number;
size: number;
tint: number; // 0 or 1: which theme color
wobble: number;
}
interface Tune {
orbit: number; // angular velocity scale
spread: number; // radius multiplier
converge: number; // 0..1 pull toward center
jitter: number; // 0..1 random shake
}
const STATE_TUNE: Record<AgentState, Tune> = {
idle: { orbit: 0.12, spread: 1.0, converge: 0, jitter: 0 },
thinking: { orbit: 0.5, spread: 1.0, converge: 0, jitter: 0 },
streaming: { orbit: 1.0, spread: 1.15, converge: 0, jitter: 0 },
'tool-calling': { orbit: 0.85, spread: 0.78, converge: 0, jitter: 0 },
waiting: { orbit: 0.16, spread: 1.05, converge: 0, jitter: 0 },
done: { orbit: 0.3, spread: 0.5, converge: 0.85, jitter: 0 },
error: { orbit: 0.35, spread: 1.2, converge: 0, jitter: 1 },
};
/**
* Particle Swarm: a cloud of glowing particles orbiting a core. They speed up
* while streaming, tighten while calling a tool, converge into a bright point
* on `done`, and scatter with a shake on `error`.
*/
export class OrbuxParticleSwarm extends Canvas2DElement {
#particles: Particle[] = [];
#cur: Tune = { ...STATE_TUNE.thinking };
protected override setup(): void {
this.#seed();
}
protected override onResize(): void {
this.#seed();
}
#seed(): void {
const count = clamp(Math.round(Math.min(this.width, this.height) * 0.55), 26, 90);
this.#particles = Array.from({ length: count }, (_, i) => ({
angle: (i / count) * TAU + Math.sin(i) * 0.4,
radius: 0.55 + ((i * 137.5) % 45) / 100,
speed: 0.6 + ((i * 53) % 80) / 100,
size: 1.1 + ((i * 29) % 22) / 10,
tint: i % 3 === 0 ? 1 : 0,
wobble: (i % 7) / 7,
}));
}
protected draw(info: OrbuxFrameInfo): void {
const ctx = this.ctx;
if (!ctx) return;
const tune = 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.orbit = lerp(this.#cur.orbit, tune.orbit, k);
this.#cur.spread = lerp(this.#cur.spread, tune.spread, k);
this.#cur.converge = lerp(this.#cur.converge, tune.converge, k);
this.#cur.jitter = lerp(this.#cur.jitter, tune.jitter, k);
const c1 = this.cssColor('--orbux-color', '#6366f1');
const c2 = this.cssColor('--orbux-color-2', '#a855f7');
const accent =
info.state === 'done'
? this.cssColor('--orbux-color-success', '#22c55e')
: info.state === 'error'
? this.cssColor('--orbux-color-error', '#ef4444')
: null;
const cx = this.width / 2;
const cy = this.height / 2;
const base = Math.min(this.width, this.height) * 0.34;
this.clear();
ctx.globalCompositeOperation = 'lighter';
for (const p of this.#particles) {
p.angle += p.speed * this.#cur.orbit * info.dt * info.speed * 3.52;
const breathe = 1 + 0.12 * Math.sin(info.elapsed * 1.6 + p.wobble * TAU);
let r = base * this.#cur.spread * p.radius * breathe;
r = lerp(r, base * 0.06, this.#cur.converge);
const jx = (Math.sin(info.elapsed * 40 + p.angle) * base * this.#cur.jitter) / 12;
const jy = (Math.cos(info.elapsed * 37 + p.angle) * base * this.#cur.jitter) / 12;
const x = cx + Math.cos(p.angle) * r + jx;
const y = cy + Math.sin(p.angle) * r + jy;
const color = accent ?? (p.tint ? c2 : c1);
const size = p.size * (1 + this.#cur.converge * 0.6);
ctx.beginPath();
ctx.arc(x, y, size, 0, TAU);
ctx.fillStyle = color;
ctx.shadowBlur = size * 3;
ctx.shadowColor = color;
ctx.fill();
}
// bright converged core on `done`
if (this.#cur.converge > 0.2) {
ctx.beginPath();
ctx.arc(cx, cy, base * 0.14 * this.#cur.converge, 0, TAU);
ctx.fillStyle = accent ?? c2;
ctx.shadowBlur = 24 * this.#cur.converge;
ctx.shadowColor = accent ?? c2;
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
ctx.shadowBlur = 0;
}
}
if (typeof customElements !== 'undefined' && !customElements.get('orbux-particle-swarm')) {
customElements.define('orbux-particle-swarm', OrbuxParticleSwarm);
}
declare global {
interface HTMLElementTagNameMap {
'orbux-particle-swarm': OrbuxParticleSwarm;
}
}
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.