All loaders

CSS / SVG · 3.3 KB gzip

Pulse Dots

Three breathing dots with state-specific timing, color, and terminal marks.

dotsminimalinlinethinkingdependency-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/pulse-dots';

<orbux-pulse-dots state="thinking"></orbux-pulse-dots>

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

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

<orbux-pulse-dots state="thinking"></orbux-pulse-dots>

Component source pulse-dots.ts

import { OrbuxElement } from '@vikast908/core';

/**
 * Pulse Dots: three breathing dots. The dependable, dependency-free
 * "thinking" indicator. Pure CSS: no render loop, no dependencies.
 *
 * States it responds to:
 *   thinking / streaming / tool-calling: animated (faster while streaming; square while tool-calling)
 *   idle / waiting                     : static poses (waiting keeps more presence)
 *   done                               : settle to the accent color
 *   error                              : shake, desaturate to error color
 */
const STYLES = /* css */ `
:host {
  --_dur: 1.2s;
  --_gap: 18%;
}
.dots {
  display: flex;
  gap: var(--_gap);
  align-items: center;
  justify-content: center;
  inline-size: 100%;
  block-size: 100%;
}
.dot {
  inline-size: 22%;
  aspect-ratio: 1 / 1;
  border-radius: 50%;
  background: radial-gradient(
    circle at 30% 30%,
    var(--orbux-color-2, #a855f7),
    var(--orbux-color, #6366f1)
  );
  animation: orbux-pulse calc(var(--_dur) * var(--orbux-speed-scale)) ease-in-out infinite;
}
.dot:nth-child(2) { animation-delay: calc(var(--_dur) * 0.16 * var(--orbux-speed-scale)); }
.dot:nth-child(3) { animation-delay: calc(var(--_dur) * 0.32 * var(--orbux-speed-scale)); }

@keyframes orbux-pulse {
  0%, 80%, 100% { transform: scale(0.45); opacity: 0.35; }
  40%           { transform: scale(1);    opacity: 1;    }
}

/* ---- state variants ---- */
:host([data-state="streaming"])    { --_dur: 0.7s; }
:host([data-state="tool-calling"]) .dot { border-radius: 22%; }
:host([data-state="idle"])         { --_dur: 3s; }
:host([data-state="waiting"])      { --_dur: 2.4s; }
:host([data-state="idle"]) .dot { animation:none;opacity:.4;transform:scale(.62); }
:host([data-state="waiting"]) .dot { animation:none;opacity:.78;transform:scale(.78); }
:host([data-state="waiting"]) .dot:nth-child(even) { opacity:.3; }

:host([data-state="done"]) .dot {
  animation: none;
  transform: scale(0.9);
  opacity: 1;
  background: var(--orbux-color-success, #22c55e);
}
:host([data-state="done"]) .dots { animation: orbux-bloom 220ms ease-out forwards; }

:host([data-state="error"]) .dot {
  animation: none;
  transform: scale(0.85);
  opacity: 0.95;
  background: var(--orbux-color-error, #ef4444);
}
:host([data-state="error"]) .dots { animation: orbux-shake 200ms ease-out; }
:host([data-settled="true"]) .dots { animation:none; }

@keyframes orbux-bloom { from { transform: scale(0.7); } to { transform: scale(1); } }
@keyframes orbux-shake {
  0%, 100% { transform: translateX(0); }
  25%      { transform: translateX(-9%); }
  75%      { transform: translateX(9%); }
}

/* ---- honor base-element run state ---- */
:host([data-paused="true"]) .dot { animation-play-state: paused; }
:host([data-reduced-motion="true"]) .dot {
  animation: none !important;
  transform: none;
  opacity: 0.85;
}
:host([data-reduced-motion="true"]) .dots { animation: none !important; transform: none; }
:host([data-reduced-motion="true"][data-state="thinking"]) .dot:nth-child(2) { transform: scale(.72); }
:host([data-reduced-motion="true"][data-state="streaming"]) .dot { opacity: 1; }
:host([data-reduced-motion="true"][data-state="tool-calling"]) .dot { border-radius: 22%; }
:host([data-reduced-motion="true"][data-state="waiting"]) .dot:nth-child(2) { opacity: .35; }
`;

export class OrbuxPulseDots extends OrbuxElement {
  protected build(): void {
    const style = document.createElement('style');
    style.textContent = STYLES;

    const wrap = document.createElement('div');
    wrap.className = 'dots';
    wrap.setAttribute('part', 'dots');
    for (let i = 0; i < 3; i++) {
      const dot = document.createElement('span');
      dot.className = 'dot';
      dot.setAttribute('part', 'dot');
      wrap.appendChild(dot);
    }

    this.root.append(style, wrap);
  }
}

if (typeof customElements !== 'undefined' && !customElements.get('orbux-pulse-dots')) {
  customElements.define('orbux-pulse-dots', OrbuxPulseDots);
}

declare global {
  interface HTMLElementTagNameMap {
    'orbux-pulse-dots': OrbuxPulseDots;
  }
}

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.