All loaders

CSS / SVG · 3.6 KB gzip

Typing Caret

A blinking caret with cascading dots that resolves to a check mark when work completes.

carettypingchatinlinestreaming
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/typing-caret';

<orbux-typing-caret state="thinking"></orbux-typing-caret>

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

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

<orbux-typing-caret state="thinking"></orbux-typing-caret>

Component source typing-caret.ts

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

/**
 * Typing Caret: a chat-style "assistant is typing" indicator: a blinking text
 * caret followed by three cascading dots. Steady while streaming, calmer while
 * thinking, a green check on `done`, red on `error`.
 */
const STYLES = /* css */ `
:host { --_dur: 1.1s; display: inline-flex; }
.row { position: relative; display: grid; place-items: center; inline-size: 100%; block-size: 100%; }
.typing {
  display: flex; align-items: center; justify-content: center; gap: 9%; inline-size: 100%; block-size: 100%;
  transition: opacity 120ms ease-out, transform 120ms ease-out;
}
.caret {
  inline-size: 8%; block-size: 58%; border-radius: 999px;
  background: var(--orbux-color, #6366f1);
  animation: orbux-blink calc(var(--_dur) * var(--orbux-speed-scale)) steps(1, end) infinite;
  transition: opacity 120ms ease-out, transform 120ms ease-out;
}
.dots {
  display: flex; align-items: center; gap: 8%; inline-size: 100%; block-size: 100%;
  transition: opacity 120ms ease-out, transform 120ms ease-out;
}
.dots i {
  inline-size: 13%; aspect-ratio: 1; border-radius: 50%;
  background: var(--orbux-color-2, #a855f7);
  animation: orbux-tc-bounce calc(var(--_dur) * var(--orbux-speed-scale)) ease-in-out infinite;
}
.dots i:nth-child(2) { animation-delay: calc(var(--_dur) * 0.18 * var(--orbux-speed-scale)); }
.dots i:nth-child(3) { animation-delay: calc(var(--_dur) * 0.36 * var(--orbux-speed-scale)); }
.check, .fail {
  position: absolute; inset: 27%; inline-size: 46%; block-size: 46%;
  opacity: 0; transform: scale(0.96); pointer-events: none;
  transition: opacity 180ms ease-out, transform 180ms ease-out;
}
.check path {
  fill: none; stroke: var(--orbux-color-success, #22c55e); stroke-width: 14;
  stroke-linecap: round; stroke-linejoin: round;
  stroke-dasharray: 60; stroke-dashoffset: 60;
}
.fail path {
  fill: none; stroke: var(--orbux-color-error, #ef4444); stroke-width: 14;
  stroke-linecap: round; stroke-linejoin: round;
  stroke-dasharray: 80; stroke-dashoffset: 80;
}

@keyframes orbux-blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0.15; } }
@keyframes orbux-tc-bounce {
  0%, 70%, 100% { transform: translateY(22%); opacity: 0.4; }
  35%           { transform: translateY(-22%); opacity: 1; }
}
@keyframes orbux-draw { to { stroke-dashoffset: 0; } }

/* ---- states ---- */
:host([data-state="thinking"])  { --_dur: 1.4s; }
:host([data-state="streaming"]) { --_dur: 0.75s; }
:host([data-state="streaming"]) .dots { gap: 5%; }
:host([data-state="idle"]),
:host([data-state="waiting"])   { --_dur: 2s; }
:host([data-state="idle"]) .dots { opacity: 0.18; }
:host([data-state="waiting"]) .dots { opacity: 0.55; }
:host([data-state="waiting"]) .dots i:nth-child(even) { opacity: 0.28; }
:host([data-state="tool-calling"]) { --_dur: 0.9s; }
:host([data-state="tool-calling"]) .dots i { border-radius: 22%; animation-timing-function: steps(2, end); }
:host([data-state="idle"]) .caret,
:host([data-state="idle"]) .dots i,
:host([data-state="waiting"]) .caret,
:host([data-state="waiting"]) .dots i { animation:none; }
:host([data-state="idle"]) .caret { opacity: .35; }
:host([data-state="waiting"]) .caret { opacity: .72; }

:host([data-state="done"]) .typing { opacity: 0; transform: scale(0.97); }
:host([data-state="done"]) .caret,
:host([data-state="done"]) .dots i { animation: none; }
:host([data-state="done"]) .check { opacity: 1; transform: scale(1); }
:host([data-state="done"]) .check path { animation: orbux-draw 220ms ease-out forwards; }

:host([data-state="error"]) .typing { opacity: 0; }
:host([data-state="error"]) .fail { opacity: 1; transform: scale(1); }
:host([data-state="error"]) .fail path { animation: orbux-draw 200ms ease-out forwards; }
:host([data-settled="true"]) .check path,
:host([data-settled="true"]) .fail path { animation: none; stroke-dashoffset: 0; }

:host([data-paused="true"]) .caret,
:host([data-paused="true"]) .dots i { animation-play-state: paused; }
:host([data-reduced-motion="true"]) .caret,
:host([data-reduced-motion="true"]) .dots i { animation: none !important; opacity: 0.85; transform: none; }
:host([data-reduced-motion="true"]) .caret,
:host([data-reduced-motion="true"]) .dots,
:host([data-reduced-motion="true"]) .typing,
:host([data-reduced-motion="true"]) .check { transition: opacity 100ms ease-out; transform: none; }
:host([data-reduced-motion="true"]) .check path,
:host([data-reduced-motion="true"]) .fail path { animation: none !important; stroke-dashoffset: 0; }
:host([data-reduced-motion="true"][data-state="streaming"]) .dots { gap: 4%; }
:host([data-reduced-motion="true"][data-state="tool-calling"]) .dots i { border-radius: 22%; }
:host([data-reduced-motion="true"][data-state="waiting"]) .caret { opacity: .35; }
`;

export class OrbuxTypingCaret extends OrbuxElement {
  protected build(): void {
    const style = document.createElement('style');
    style.textContent = STYLES;
    const row = document.createElement('div');
    row.className = 'row';
    row.innerHTML =
      '<span class="typing"><span class="caret" part="caret"></span>' +
      '<span class="dots"><i></i><i></i><i></i></span></span>' +
      '<svg class="check" part="check" viewBox="0 0 100 100" aria-hidden="true"><path d="M20 54 L42 76 L82 28"/></svg>' +
      '<svg class="fail" part="fail" viewBox="0 0 100 100" aria-hidden="true"><path d="M28 28 L72 72"/><path d="M72 28 L28 72"/></svg>';
    this.root.append(style, row);
  }
}

if (typeof customElements !== 'undefined' && !customElements.get('orbux-typing-caret')) {
  customElements.define('orbux-typing-caret', OrbuxTypingCaret);
}

declare global {
  interface HTMLElementTagNameMap {
    'orbux-typing-caret': OrbuxTypingCaret;
  }
}

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.