All loaders

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

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

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.