Compact animated activity indicators for agent UIs — discrete DOM and CSS orbs that signal what the agent is doing without blocking the thread.
import type { CSSProperties } from "react";
import styles from "./Orb.module.css";
/** The stage the geometry is tuned on; --orb-k scales it to `size`. */
const STAGE = 28;
/** Default rendered size — 20×20 indicator box. */
const SIZE = 20;
export type LatticeVariant = "S1" | "S2" | "S3" | "S4" | "S5";
export type LensVariant = "B1" | "B2" | "B3" | "B4" | "B5";
export type RingVariant = "C1" | "C2" | "C3" | "C4" | "C5";
export type HelixVariant = "G1" | "G2" | "G3" | "G4" | "G5";
export type MorphVariant = "M1" | "M2" | "M3" | "M4" | "M5";
export type OrbVariant = LatticeVariant | LensVariant | RingVariant | HelixVariant | MorphVariant;
export const LATTICE_VARIANTS: LatticeVariant[] = ["S1", "S2", "S3", "S4", "S5"];
export const LENS_VARIANTS: LensVariant[] = [
"B1",
"B2",
"B3",
"B4",
"B5",
];
export const RING_VARIANTS: RingVariant[] = ["C1", "C2", "C3", "C4", "C5"];
export const HELIX_VARIANTS: HelixVariant[] = ["G1", "G2", "G3", "G4", "G5"];
export const MORPH_VARIANTS: MorphVariant[] = ["M1", "M2", "M3", "M4", "M5"];
export const ORB_TASKS: Record<OrbVariant, string> = {
S1: "Thinking",
S2: "Processing",
S3: "Working",
S4: "Searching",
S5: "Finalizing",
B1: "Thinking",
B2: "Searching",
B3: "Generating",
B4: "Solving",
B5: "Routing",
C1: "Loading",
C2: "Listening",
C3: "Streaming",
C4: "Analyzing",
C5: "Compiling",
G1: "Processing",
G2: "Sequencing",
G3: "Uploading",
G4: "Syncing",
G5: "Idling",
M1: "Shaping",
M2: "Expanding",
M3: "Unfolding",
M4: "Transforming",
M5: "Dispersing",
};
function isLattice(v: OrbVariant): v is LatticeVariant {
return (LATTICE_VARIANTS as OrbVariant[]).includes(v);
}
function isRing(v: OrbVariant): v is RingVariant {
return (RING_VARIANTS as OrbVariant[]).includes(v);
}
function isHelix(v: OrbVariant): v is HelixVariant {
return (HELIX_VARIANTS as OrbVariant[]).includes(v);
}
function isMorph(v: OrbVariant): v is MorphVariant {
return (MORPH_VARIANTS as OrbVariant[]).includes(v);
}
const N = 3; // lattice is N×N
const PITCH = 6; // centre-to-centre spacing in stage px; the dot size is CSS
const MID = (N - 1) / 2;
/** Clockwise walk of the lattice perimeter — the track `orbit` runs on. */
const RING: [number, number][] = (() => {
const ring: [number, number][] = [];
for (let x = 0; x < N; x++) ring.push([x, 0]);
for (let y = 1; y < N; y++) ring.push([N - 1, y]);
for (let x = N - 2; x >= 0; x--) ring.push([x, N - 1]);
for (let y = N - 2; y >= 1; y--) ring.push([0, y]);
return ring;
})();
const RING_INDEX = new Map(RING.map(([x, y], i) => [x + "," + y, i]));
/**
* Per-cell `animation-delay` in ms. Negative values seed a cell partway
* into its cycle, which is what turns 8 identical animations into one
* comet travelling the ring.
*/
function cellDelay(v: LatticeVariant, x: number, y: number): number {
const dx = x - MID;
const dy = y - MID;
const ring = Math.max(Math.abs(dx), Math.abs(dy));
switch (v) {
// Radiates from the centre on a round wavefront. Centre leads a beat
// early so the next swell doesn't sit behind the outer fade.
case "S1":
return Math.hypot(dx, dy) * 700 - (dx === 0 && dy === 0 ? 180 : 0);
// A broad band crosses the grid on the diagonal. The spread is close to
// the wave duration, which both widens the band and makes the sweep
// continuous — the far corner restarts as the near one does.
case "S2":
return ((x + y) / (2 * (N - 1))) * 1500;
// One head with a decaying tail, running the perimeter clockwise.
case "S3": {
const i = RING_INDEX.get(x + "," + y);
if (i === undefined) return 0;
return -(((RING.length - i) % RING.length) / RING.length) * 1700;
}
// A soft column travels left to right.
case "S4":
return (x / (N - 1)) * 1100;
// Like S3 but scrambled order — the pulse jumps pseudo-randomly.
case "S5": {
const i = RING_INDEX.get(x + "," + y);
if (i === undefined) return 0;
const scrambled = (i * 3) % RING.length;
return -(scrambled / RING.length) * 1700;
}
}
}
/**
* `settle` gathers each cell from a position rotated one way around the
* centre and releases it to the mirror rotation, so the cycle keeps swirling
* the same way instead of rewinding to where it came from.
*/
const SWIRL = 1.05; // radians of rotation at each end, ~60°
const SPREAD = 1.6; // outward push, on top of the rotation
/** Offset from a cell's own grid slot to its swirled position, in stage px. */
function swirl(x: number, y: number, angle: number): [number, number] {
const dx = x - MID;
const dy = y - MID;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return [
((dx * cos - dy * sin) * SPREAD - dx) * PITCH,
((dx * sin + dy * cos) * SPREAD - dy) * PITCH,
];
}
interface Cell {
key: string;
left: number;
top: number;
delay: number;
/** Where `settle` gathers this cell from, and releases it to. */
ax: number;
ay: number;
bx: number;
by: number;
/** Sits out the choreography (interior cells during `orbit`). */
still: boolean;
/** Centre cell — the static frame under reduced motion. */
mid: boolean;
}
/** The 9 lattice cells, with position, phase and swirl vectors. */
function latticeCells(v: LatticeVariant): Cell[] {
const cells: Cell[] = [];
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const [ax, ay] = swirl(x, y, -SWIRL);
const [bx, by] = swirl(x, y, SWIRL);
cells.push({
key: x + "," + y,
left: x * PITCH,
top: y * PITCH,
delay: cellDelay(v, x, y),
ax,
ay,
bx,
by,
still: (v === "S3" || v === "S5") && !RING_INDEX.has(x + "," + y),
mid: x === MID && y === MID,
});
}
}
return cells;
}
const RING_N = 8;
const RING_R = 8;
interface RingDot {
key: number;
rx: number;
ry: number;
delay: number;
}
function ringDuration(v: RingVariant): number {
switch (v) {
case "C1": return 1600;
case "C2": return 2000;
case "C3": return 1800;
case "C4": return 1600;
case "C5": return 2200;
}
}
function ringDelay(v: RingVariant, i: number): number {
const dur = ringDuration(v);
switch (v) {
case "C1":
return -((RING_N - 1 - i) / RING_N) * dur;
case "C2":
case "C3":
return -((RING_N - 1 - i) / RING_N) * dur;
case "C4":
return i % 2 === 0 ? 0 : -(dur / 2);
case "C5": {
const scrambled = (i * 3) % RING_N;
return -(scrambled / RING_N) * dur;
}
default:
return -(i / RING_N) * dur;
}
}
function ringDots(v: RingVariant): RingDot[] {
const dots: RingDot[] = [];
for (let i = 0; i < RING_N; i++) {
const angle = (i / RING_N) * Math.PI * 2 - Math.PI / 2;
dots.push({
key: i,
rx: Math.cos(angle) * RING_R,
ry: Math.sin(angle) * RING_R,
delay: ringDelay(v, i),
});
}
return dots;
}
const GLOBE_R = 8.5;
const GLOBE_TILT = (14 * Math.PI) / 180;
const GLOBE_STEPS = 8;
const GLOBE_RINGS: { lat: number; count: number }[] = [
{ lat: 52, count: 8 },
{ lat: 26, count: 8 },
{ lat: 0, count: 8 },
{ lat: -26, count: 8 },
{ lat: -52, count: 8 },
];
interface GlobeDot {
key: number;
style: Record<string, string>;
css: string;
}
function projectGlobe(x: number, y: number, z: number, spin: number) {
const cs = Math.cos(spin);
const ss = Math.sin(spin);
const x1 = x * cs - z * ss;
const z1 = x * ss + z * cs;
const y1 = y;
const ct = Math.cos(GLOBE_TILT);
const st = Math.sin(GLOBE_TILT);
return {
x: x1,
y: y1 * ct - z1 * st,
z: y1 * st + z1 * ct,
};
}
function globeOpacity(z: number) {
const t = Math.max(0, Math.min(1, (z / GLOBE_R + 0.15) / 1.15));
return 0.12 + 0.88 * t * t;
}
type RingMove = { ring: number; angle: number };
const RING_HALF = Math.PI;
const RING_ARC = 3;
function ringDir(ring: number) {
return ring % 2 === 0 ? -1 : 1;
}
const G3_MOVES: RingMove[] = (() => {
const moves: RingMove[] = [];
for (let pass = 0; pass < 2; pass++) {
for (let r = 0; r < GLOBE_RINGS.length; r++) {
moves.push({ ring: r, angle: ringDir(r) * RING_HALF });
}
}
return moves;
})();
const G4_MOVES: RingMove[] = [2, 1, 3, 0, 4, 2, 1, 3, 0, 4].map((ring) => ({
ring,
angle: ringDir(ring) * RING_HALF,
}));
function ringTurnPoses(
x0: number,
y0: number,
z0: number,
ringIndex: number,
moves: RingMove[],
): [number, number, number][] {
let x = x0;
let y = y0;
let z = z0;
const poses: [number, number, number][] = [[x, y, z]];
for (let m = 0; m < moves.length; m++) {
const move = moves[m];
const xS = x;
const yS = y;
const zS = z;
for (let s = 1; s <= RING_ARC; s++) {
if (ringIndex === move.ring) {
const a = move.angle * (s / RING_ARC);
const c = Math.cos(a);
const sn = Math.sin(a);
x = xS * c - zS * sn;
y = yS;
z = xS * sn + zS * c;
}
poses.push([x, y, z]);
}
}
return poses;
}
const G5_SLOW = 0.4;
const G5_BURST = (Math.PI * 2 - G5_SLOW * 4) / 4;
const G5_POSES: { s: number; spin: number }[] = (() => {
const poses: { s: number; spin: number }[] = [{ s: 1.0, spin: 0 }];
let spin = 0;
const steps: { s: number; kind: "slow" | "burst" }[] = [
{ s: 1.0, kind: "slow" },
{ s: 0.9, kind: "burst" },
{ s: 0.9, kind: "slow" },
{ s: 0.8, kind: "burst" },
{ s: 0.8, kind: "slow" },
{ s: 0.9, kind: "burst" },
{ s: 0.9, kind: "slow" },
{ s: 1.0, kind: "burst" },
];
for (const step of steps) {
spin += step.kind === "slow" ? G5_SLOW : G5_BURST;
poses.push({ s: step.s, spin });
}
return poses;
})();
function globeKeyframeStyle(
x0: number,
y0: number,
z0: number,
variant: HelixVariant,
ringIndex: number,
j = 0,
): Record<string, string> {
const style: Record<string, string> = {};
if (variant === "G5") {
for (let k = 0; k < G5_POSES.length; k++) {
const sc = G5_POSES[k].s;
const spin = G5_POSES[k].spin;
const p = projectGlobe(x0 * sc, y0 * sc, z0 * sc, spin);
style["--g" + k + "x"] = p.x.toFixed(2) + "px";
style["--g" + k + "y"] = (-p.y).toFixed(2) + "px";
style["--g" + k + "o"] = globeOpacity(p.z).toFixed(3);
}
return style;
}
if (variant === "G3" || variant === "G4") {
const poses = ringTurnPoses(
x0,
y0,
z0,
ringIndex,
variant === "G3" ? G3_MOVES : G4_MOVES,
);
for (let k = 0; k < poses.length; k++) {
const pos = poses[k];
const p = projectGlobe(pos[0], pos[1], pos[2], 0);
style["--g" + k + "x"] = p.x.toFixed(2) + "px";
style["--g" + k + "y"] = (-p.y).toFixed(2) + "px";
style["--g" + k + "o"] = globeOpacity(p.z).toFixed(3);
}
return style;
}
const dir = variant === "G2" && ringIndex % 2 === 1 ? -1 : 1;
for (let k = 0; k < GLOBE_STEPS; k++) {
const phase = k / GLOBE_STEPS;
const spin = dir * phase * Math.PI * 2;
const p = projectGlobe(x0, y0, z0, spin);
style["--g" + k + "x"] = p.x.toFixed(2) + "px";
style["--g" + k + "y"] = (-p.y).toFixed(2) + "px";
style["--g" + k + "o"] = globeOpacity(p.z).toFixed(3);
}
return style;
}
function globeDots(v: HelixVariant): GlobeDot[] {
const dots: GlobeDot[] = [];
let idx = 0;
for (let ringIndex = 0; ringIndex < GLOBE_RINGS.length; ringIndex++) {
const ring = GLOBE_RINGS[ringIndex];
const latRad = (ring.lat * Math.PI) / 180;
const y0 = Math.sin(latRad) * GLOBE_R;
const ringR = Math.cos(latRad) * GLOBE_R;
for (let j = 0; j < ring.count; j++) {
const lon = (j / ring.count) * Math.PI * 2;
const style = globeKeyframeStyle(
Math.cos(lon) * ringR,
y0,
Math.sin(lon) * ringR,
v,
ringIndex,
j,
);
dots.push({
key: idx,
style,
css: Object.keys(style)
.map((k) => k + ":" + style[k])
.join(";"),
});
idx++;
}
}
return dots;
}
const MORPH_N = 8;
const MORPH_R = 7;
type ShapeFn = (i: number) => [number, number];
const shapeCircle: ShapeFn = (i) => {
const a = (i / MORPH_N) * Math.PI * 2 - Math.PI / 2;
return [Math.cos(a) * MORPH_R, Math.sin(a) * MORPH_R];
};
const shapeOctagon: ShapeFn = (i) => {
const a = (i / MORPH_N) * Math.PI * 2 - Math.PI / 2;
const r = MORPH_R * 0.92;
const sector = Math.round(a / (Math.PI / 4)) * (Math.PI / 4);
return [Math.cos(sector) * r, Math.sin(sector) * r];
};
const shapeSquare: ShapeFn = (i) => {
const h = MORPH_R * 0.85;
const corners: [number, number][] = [[-h, -h], [h, -h], [h, h], [-h, h]];
const t = ((i / MORPH_N) * 4 + 0.5) % 4;
const side = Math.floor(t) % 4;
const frac = t - Math.floor(t);
const from = corners[side];
const to = corners[(side + 1) % 4];
return [from[0] + (to[0] - from[0]) * frac, from[1] + (to[1] - from[1]) * frac];
};
const shapeCircleAt =
(turn: number): ShapeFn =>
(i) => {
const a = (i / MORPH_N) * Math.PI * 2 - Math.PI / 2 + turn;
return [Math.cos(a) * MORPH_R, Math.sin(a) * MORPH_R];
};
const SCATTER_TRAIL = 0.12;
const shapeScatterA: ShapeFn = (i) => {
const a = (i / MORPH_N) * Math.PI * 2 - Math.PI / 2;
return [-Math.cos(a) * MORPH_R, Math.sin(a) * MORPH_R];
};
const shapeScatterB: ShapeFn = shapeCircle;
const shapeScatterC: ShapeFn = shapeScatterA;
const shapeDiamond: ShapeFn = (i) => {
const corners: [number, number][] = [[0, -MORPH_R], [MORPH_R, 0], [0, MORPH_R], [-MORPH_R, 0]];
const t = (i / MORPH_N) * 4;
const side = Math.floor(t) % 4;
const frac = t - Math.floor(t);
const from = corners[side];
const to = corners[(side + 1) % 4];
return [from[0] + (to[0] - from[0]) * frac, from[1] + (to[1] - from[1]) * frac];
};
const shapeCenter: ShapeFn = (i) => {
const a = (i / MORPH_N) * Math.PI * 2 - Math.PI / 2;
return [Math.cos(a) * 1.5, Math.sin(a) * 1.5];
};
function morphShapes(v: MorphVariant): [ShapeFn, ShapeFn, ShapeFn, ShapeFn] {
switch (v) {
case "M1": return [shapeCircle, shapeSquare, shapeDiamond, shapeSquare];
case "M2": return [shapeCenter, shapeCircle, shapeCenter, shapeCircle];
case "M3":
return [
shapeCircleAt(0),
shapeCircleAt(Math.PI / 2),
shapeCircleAt(Math.PI),
shapeCircleAt(Math.PI * 1.5),
];
case "M4": return [shapeCircle, shapeDiamond, shapeCircle, shapeDiamond];
case "M5": return [shapeCircle, shapeScatterA, shapeScatterB, shapeScatterC];
}
}
interface MorphDot {
key: number;
m1: string;
m2: string;
m3: string;
m4: string;
delay?: string;
depth?: string;
}
function morphDots(v: MorphVariant): MorphDot[] {
const [s1, s2, s3, s4] = morphShapes(v);
const dots: MorphDot[] = [];
for (let i = 0; i < MORPH_N; i++) {
const [x1, y1] = s1(i);
const [x2, y2] = s2(i);
const [x3, y3] = s3(i);
const [x4, y4] = s4(i);
dots.push({
key: i,
m1: x1.toFixed(1) + "px, " + y1.toFixed(1) + "px",
m2: x2.toFixed(1) + "px, " + y2.toFixed(1) + "px",
m3: x3.toFixed(1) + "px, " + y3.toFixed(1) + "px",
m4: x4.toFixed(1) + "px, " + y4.toFixed(1) + "px",
delay: v === "M5" ? -i * 10 + "ms" : undefined,
depth: v === "M5" ? Math.abs(Math.cos((i / MORPH_N) * Math.PI * 2 - Math.PI / 2)).toFixed(2) : undefined,
});
}
return dots;
}
export interface OrbProps {
variant?: OrbVariant;
/** Rendered edge length in px. The 28px geometry scales to fit. */
size?: number;
/** Accessible label, and the status text when `pill` is set. */
label?: string;
/** Wraps the orb and its label in a status pill. */
pill?: boolean;
className?: string;
style?: CSSProperties;
}
export function Orb({
variant = "S1",
size = SIZE,
label,
pill,
className,
style,
}: OrbProps) {
const text = label ?? ORB_TASKS[variant] + "…";
return (
<span
className={styles.root + (className ? " " + className : "")}
data-pill={pill ? "" : undefined}
style={style}
>
<span
className={styles.glyph}
// In pill form the visible label already carries the meaning, so
// the glyph steps out of the accessibility tree.
role={pill ? undefined : "img"}
aria-label={pill ? undefined : text}
aria-hidden={pill ? true : undefined}
style={
{ width: size, height: size, "--orb-k": size / STAGE } as CSSProperties
}
>
{isLattice(variant) ? (
<span className={styles.lattice} data-variant={variant}>
{latticeCells(variant).map((c) => (
<span
key={c.key}
className={styles.cell}
data-still={c.still ? "" : undefined}
data-mid={c.mid ? "" : undefined}
style={
{
left: c.left,
top: c.top,
animationDelay: c.delay + "ms",
"--orb-ax": c.ax + "px",
"--orb-ay": c.ay + "px",
"--orb-bx": c.bx + "px",
"--orb-by": c.by + "px",
} as CSSProperties
}
/>
))}
</span>
) : isRing(variant) ? (
<span className={styles.ring} data-variant={variant}>
{ringDots(variant).map((d) => (
<span
key={d.key}
className={styles.ringDot}
style={
{
"--orb-rx": d.rx + "px",
"--orb-ry": d.ry + "px",
animationDelay: d.delay + "ms",
} as CSSProperties
}
/>
))}
</span>
) : isHelix(variant) ? (
<span className={styles.helix} data-variant={variant}>
{globeDots(variant).map((d) => (
<span
key={d.key}
className={styles.helixDot}
style={d.style as CSSProperties}
/>
))}
</span>
) : isMorph(variant) ? (
<span className={styles.morph} data-variant={variant}>
{morphDots(variant).map((d) => (
<span
key={d.key}
className={styles.morphDot}
style={
{
"--m-1": d.m1,
"--m-2": d.m2,
"--m-3": d.m3,
"--m-4": d.m4,
"--m-depth": d.depth,
animationDelay: d.delay,
} as CSSProperties
}
/>
))}
</span>
) : (
<span className={styles.lens} data-variant={variant}>
<span className={styles.shape + " " + styles.shapeA} />
<span className={styles.shape + " " + styles.shapeB} />
<span className={styles.shape + " " + styles.shapeC} />
{/* focus is the one variant that needs a fourth circle: its cast
sits on the corners of a square, and three corners do not
make a square. */}
{variant === "B1" && (
<span className={styles.shape + " " + styles.shapeD} />
)}
</span>
)}
</span>
{pill && <span className={styles.pillLabel}>{text}</span>}
</span>
);
}
/* Usage:
<Orb variant="S4" />
<Orb variant="B4" size={40} />
<Orb variant="C3" />
<Orb variant="B2" label="Searching the web…" pill />
*/