All loaders

CSS / SVG · 3.5 KB gzip

Wave Bars

Five equalizer bars bouncing in a wave: tall and fast while streaming, flat green on done.

equalizeraudiobarsinlinedependency-free
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/wave-bars';

<orbux-wave-bars state="thinking"></orbux-wave-bars>

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

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

<orbux-wave-bars state="thinking"></orbux-wave-bars>

Component source wave-bars.ts

import { type AgentState, OrbuxElement } from '@vikast908/core';

const TERMINAL_SCALES: Partial<Record<AgentState, readonly number[]>> = {
  done: [0.42, 0.42, 0.42, 0.42, 0.42],
  error: [0.5, 0.95, 0.35, 0.8, 0.45],
};

/**
 * Wave Bars: five equalizer bars bouncing in a wave. Tall and fast while
 * streaming, calm while idle, flat green on done, jagged red on error.
 */
const STYLES = /* css */ `
:host { --_dur: 1s; --_min: 0.3; }
.bars {
  display: flex;
  gap: 12%;
  align-items: center;
  justify-content: center;
  inline-size: 100%;
  block-size: 100%;
}
.bar {
  inline-size: 11%;
  block-size: 72%;
  border-radius: 99px;
  transform: scaleY(var(--_min));
  background: linear-gradient(var(--orbux-color-2, #a855f7), var(--orbux-color, #6366f1));
  animation: orbux-wave calc(var(--_dur) * var(--orbux-speed-scale)) ease-in-out infinite;
  transition: transform 180ms ease-out, opacity 180ms ease-out;
}
.bar:nth-child(1) { animation-delay: calc(var(--_dur) * 0.0 * var(--orbux-speed-scale)); }
.bar:nth-child(2) { animation-delay: calc(var(--_dur) * 0.12 * var(--orbux-speed-scale)); }
.bar:nth-child(3) { animation-delay: calc(var(--_dur) * 0.24 * var(--orbux-speed-scale)); }
.bar:nth-child(4) { animation-delay: calc(var(--_dur) * 0.12 * var(--orbux-speed-scale)); }
.bar:nth-child(5) { animation-delay: calc(var(--_dur) * 0.0 * var(--orbux-speed-scale)); }

@keyframes orbux-wave {
  0%, 100% { transform: scaleY(var(--_min)); }
  50%      { transform: scaleY(1); }
}

/* ---- states ---- */
:host([data-state="thinking"])     { --_dur: 1s;    --_min: 0.32; }
:host([data-state="streaming"])    { --_dur: 0.55s; --_min: 0.16; }
:host([data-state="tool-calling"]) { --_dur: 0.7s;  --_min: 0.24; }
:host([data-state="idle"])         { --_dur: 2.2s;  --_min: 0.5; }
:host([data-state="waiting"])      { --_dur: 1.9s;  --_min: 0.55; }
:host([data-state="idle"]) .bar,
:host([data-state="waiting"]) .bar { animation:none;transform:scaleY(var(--_min)); }

:host([data-state="done"]) .bar {
  background: var(--orbux-color-success, #22c55e);
  animation: none;
}
:host([data-state="error"]) .bar {
  background: var(--orbux-color-error, #ef4444);
  animation: none;
}
:host([data-settled="true"]) .bar { animation: none; }

/* ---- run state ---- */
:host([data-paused="true"]) .bar { animation-play-state: paused; }
:host([data-reduced-motion="true"]) .bar {
  animation: none !important;
  transform: scaleY(0.6);
  transition: none;
}
:host([data-reduced-motion="true"][data-state="thinking"]) .bar:nth-child(3) { transform:scaleY(.82); }
:host([data-reduced-motion="true"][data-state="streaming"]) .bar:nth-child(2),
:host([data-reduced-motion="true"][data-state="streaming"]) .bar:nth-child(4) { transform:scaleY(.9); }
:host([data-reduced-motion="true"][data-state="tool-calling"]) .bar { border-radius:2px; }
:host([data-reduced-motion="true"][data-state="waiting"]) .bar { opacity:.48; }
:host([data-reduced-motion="true"][data-state="done"]) .bar { transform: scaleY(0.42); }
:host([data-reduced-motion="true"][data-state="error"]) .bar:nth-child(1) { transform: scaleY(0.5); }
:host([data-reduced-motion="true"][data-state="error"]) .bar:nth-child(2) { transform: scaleY(0.95); }
:host([data-reduced-motion="true"][data-state="error"]) .bar:nth-child(3) { transform: scaleY(0.35); }
:host([data-reduced-motion="true"][data-state="error"]) .bar:nth-child(4) { transform: scaleY(0.8); }
:host([data-reduced-motion="true"][data-state="error"]) .bar:nth-child(5) { transform: scaleY(0.45); }
`;

export class OrbuxWaveBars extends OrbuxElement {
  #bars: HTMLElement[] = [];
  #settleFrame = 0;
  #opacityBridges: Animation[] = [];

  protected build(): void {
    const style = document.createElement('style');
    style.textContent = STYLES;
    const bars = document.createElement('div');
    bars.className = 'bars';
    bars.setAttribute('part', 'bars');
    for (let i = 0; i < 5; i++) {
      const bar = document.createElement('span');
      bar.className = 'bar';
      bar.setAttribute('part', 'bar');
      this.#bars.push(bar);
      bars.appendChild(bar);
    }
    this.root.append(style, bars);
  }

  protected override onStateChange(next: AgentState): void {
    if (this.#settleFrame) cancelAnimationFrame(this.#settleFrame);
    this.#settleFrame = 0;
    for (const animation of this.#opacityBridges) animation.cancel();
    this.#opacityBridges = [];
    const scales = TERMINAL_SCALES[next];
    if (!scales) {
      for (const bar of this.#bars) {
        bar.style.removeProperty('animation');
        bar.style.removeProperty('transform');
      }
      return;
    }

    for (const bar of this.#bars) {
      const current = getComputedStyle(bar).transform;
      bar.style.transform = current === 'none' ? 'scaleY(1)' : current;
      bar.style.animation = 'none';
    }
    if (this.reducedMotion) {
      this.#applyScales(scales);
      this.#opacityBridges = this.#bars.map((bar) =>
        bar.animate([{ opacity: 0.72 }, { opacity: 1 }], {
          duration: 100,
          easing: 'ease-out',
        }),
      );
      return;
    }
    this.#settleFrame = requestAnimationFrame(() => {
      this.#settleFrame = 0;
      if (this.state === next) this.#applyScales(scales);
    });
  }

  #applyScales(scales: readonly number[]): void {
    for (const [index, bar] of this.#bars.entries()) {
      bar.style.transform = `scaleY(${scales[index] ?? 0.42})`;
    }
  }

  override disconnectedCallback(): void {
    if (this.#settleFrame) cancelAnimationFrame(this.#settleFrame);
    this.#settleFrame = 0;
    for (const animation of this.#opacityBridges) animation.cancel();
    this.#opacityBridges = [];
    super.disconnectedCallback();
  }
}

if (typeof customElements !== 'undefined' && !customElements.get('orbux-wave-bars')) {
  customElements.define('orbux-wave-bars', OrbuxWaveBars);
}

declare global {
  interface HTMLElementTagNameMap {
    'orbux-wave-bars': OrbuxWaveBars;
  }
}

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.