← All particles

Thinking Orb

A cloud of points that changes shape to say whether it is waiting, thinking, or answering.

statusfuturisticcalm200 particles · moderate · canvas-2d · automatic · looping
Variant

The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

290 lines · react only
import { useEffect, useRef } from "react";

/**
 * Vibary · Thinking Orb
 *
 * A sphere of points that changes shape to say what it is doing: a
 * tilted ring while it waits, a connected web while it thinks, a
 * flowing ribbon while it answers.
 *
 * The point that makes it read as one object rather than three
 * animations: the point count never changes. Every form is the same N
 * points rearranged, matched nearest-neighbour to their new positions,
 * so nothing is created or destroyed — the orb *becomes* the next
 * shape instead of dissolving into it.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `mode`, `count`, `color`, `size`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ThinkingOrbMode = "idle" | "thinking" | "answering";

export type ThinkingOrbProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Which of the three forms the orb holds. */
  mode?: ThinkingOrbMode;
  /** Points in the cloud. Constant across every form, by design. */
  count?: number;
  /** Point color; the web strands derive from it. */
  color?: string;
  /** Drawing box in px. */
  size?: number;
};

type VariantConfig = {
  /** Turns per second while thinking; other modes scale off this. */
  spin: number;
  /** Seconds to travel between two forms. */
  morphSeconds: number;
  /** Point radius in px at the default size. */
  dot: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  subtle: { spin: 0.06, morphSeconds: 0.9, dot: 1.5 },
  default: { spin: 0.1, morphSeconds: 0.7, dot: 1.8 },
  playful: { spin: 0.16, morphSeconds: 0.5, dot: 2.1 },
};

type Vec3 = { x: number; y: number; z: number };

/** Fibonacci sphere — even coverage without clustering at the poles. */
function sphere(count: number, radius: number): Vec3[] {
  const points: Vec3[] = [];
  const golden = Math.PI * (3 - Math.sqrt(5));
  for (let index = 0; index < count; index++) {
    const y = 1 - (index / (count - 1)) * 2;
    const ring = Math.sqrt(Math.max(0, 1 - y * y));
    const theta = golden * index;
    points.push({
      x: Math.cos(theta) * ring * radius,
      y: y * radius,
      z: Math.sin(theta) * ring * radius,
    });
  }
  return points;
}

/** Idle: the sphere pressed toward its equator, then tilted — a ring. */
function ringForm(count: number, radius: number): Vec3[] {
  return sphere(count, radius).map((point, index) => {
    const flat = { x: point.x, y: point.y * 0.12, z: point.z };
    const tilt = 0.42;
    // Rotate about x so the ring reads as a disc seen at an angle.
    const spread = 1 + (index % 3) * 0.06;
    return {
      x: flat.x * spread,
      y: flat.y * Math.cos(tilt) - flat.z * Math.sin(tilt),
      z: flat.y * Math.sin(tilt) + flat.z * Math.cos(tilt),
    };
  });
}

/** Thinking: a lumpy sphere — a web with visible nodes. */
function webForm(count: number, radius: number): Vec3[] {
  return sphere(count, radius).map((point, index) => {
    const lump = 1 + Math.sin(index * 2.399) * 0.16;
    return { x: point.x * lump, y: point.y * lump, z: point.z * lump };
  });
}

/** Answering: points gathered onto a ribbon winding around the sphere. */
function ribbonForm(count: number, radius: number): Vec3[] {
  const points: Vec3[] = [];
  for (let index = 0; index < count; index++) {
    const t = index / count;
    const turns = 3.5;
    const angle = t * Math.PI * 2 * turns;
    const height = (t - 0.5) * 2;
    const ring = Math.sqrt(Math.max(0, 1 - height * height));
    // A little thickness so it is a ribbon rather than a wire.
    const band = ((index % 5) - 2) * radius * 0.045;
    points.push({
      x: Math.cos(angle) * ring * radius + band * 0.3,
      y: height * radius * 0.92 + band,
      z: Math.sin(angle) * ring * radius,
    });
  }
  return points;
}

export default function ThinkingOrb({
  variant = "default",
  mode = "thinking",
  count = 200,
  color = "#7C7CF0",
  size = 132,
}: ThinkingOrbProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The loop reads the mode through a ref so a mode change does not tear
  // down and rebuild the point cloud. Assigned in an effect rather than
  // during render, which would be a side effect mid-render.
  const modeRef = useRef(mode);
  useEffect(() => {
    modeRef.current = mode;
  }, [mode]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const context = canvas.getContext("2d");
    if (!context) return;

    const config = VARIANTS[variant];
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const ratio = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = size * ratio;
    canvas.height = size * ratio;
    context.setTransform(ratio, 0, 0, ratio, 0, 0);

    const radius = size * 0.34;
    const forms: Record<ThinkingOrbMode, Vec3[]> = {
      idle: ringForm(count, radius),
      thinking: webForm(count, radius),
      answering: ribbonForm(count, radius),
    };

    // Points travel to the nearest free slot in the next form. Without
    // this the cloud shears — points cross the sphere to reach an index
    // that happens to share their position in the array.
    const pairing = (from: Vec3[], to: Vec3[]) => {
      const taken = new Array(to.length).fill(false);
      return from.map((point) => {
        let best = -1;
        let bestDistance = Infinity;
        for (let index = 0; index < to.length; index++) {
          if (taken[index]) continue;
          const target = to[index];
          const distance =
            (point.x - target.x) ** 2 +
            (point.y - target.y) ** 2 +
            (point.z - target.z) ** 2;
          if (distance < bestDistance) {
            bestDistance = distance;
            best = index;
          }
        }
        taken[best] = true;
        return to[best];
      });
    };

    let current = forms[modeRef.current].map((point) => ({ ...point }));
    let origin = current.map((point) => ({ ...point }));
    let target = pairing(origin, forms[modeRef.current]);
    let shownMode = modeRef.current;
    let morph = 1;

    const speedFor = (value: ThinkingOrbMode) =>
      value === "idle" ? 0.25 : value === "answering" ? 1 : 1;

    let angle = 0;
    let frame = 0;
    let last = performance.now();

    const render = () => {
      context.clearRect(0, 0, size, size);
      const centre = size / 2;
      const eased = morph < 1 ? 1 - Math.pow(1 - morph, 3) : 1;

      const projected = current.map((point, index) => {
        const to = target[index];
        const x = point.x + (to.x - point.x) * eased;
        const y = point.y + (to.y - point.y) * eased;
        const z = point.z + (to.z - point.z) * eased;
        // One rotation about y, then a fixed perspective divide.
        const rx = x * Math.cos(angle) - z * Math.sin(angle);
        const rz = x * Math.sin(angle) + z * Math.cos(angle);
        const depth = 1 / (1 + (rz / (radius * 4)) * 0.6);
        return {
          screenX: centre + rx * depth,
          screenY: centre + y * depth,
          depth,
        };
      });

      // Strands only while thinking: they are what makes the web read as
      // connected rather than as a denser cloud.
      if (shownMode === "thinking" || modeRef.current === "thinking") {
        context.strokeStyle = color;
        context.lineWidth = 0.5;
        for (let index = 0; index < projected.length; index += 7) {
          const from = projected[index];
          const to = projected[(index + 13) % projected.length];
          const span = Math.hypot(from.screenX - to.screenX, from.screenY - to.screenY);
          if (span > size * 0.34) continue;
          context.globalAlpha = 0.16 * from.depth;
          context.beginPath();
          context.moveTo(from.screenX, from.screenY);
          context.lineTo(to.screenX, to.screenY);
          context.stroke();
        }
      }

      context.fillStyle = color;
      for (const point of projected) {
        context.globalAlpha = 0.35 + point.depth * 0.55;
        context.beginPath();
        context.arc(point.screenX, point.screenY, config.dot * point.depth, 0, Math.PI * 2);
        context.fill();
      }
      context.globalAlpha = 1;
    };

    // Reduced motion: the current form, held still. The mode is the
    // information and it is still legible without rotation.
    if (reduced) {
      morph = 1;
      render();
      return;
    }

    const tick = (now: number) => {
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;

      if (modeRef.current !== shownMode) {
        // Freeze where the points are right now, then re-pair from there
        // so a mode change mid-morph doesn't snap.
        const eased = 1 - Math.pow(1 - morph, 3);
        origin = current.map((point, index) => ({
          x: point.x + (target[index].x - point.x) * eased,
          y: point.y + (target[index].y - point.y) * eased,
          z: point.z + (target[index].z - point.z) * eased,
        }));
        current = origin.map((point) => ({ ...point }));
        target = pairing(origin, forms[modeRef.current]);
        shownMode = modeRef.current;
        morph = 0;
      }

      if (morph < 1) morph = Math.min(1, morph + delta / config.morphSeconds);
      angle += config.spin * speedFor(shownMode) * Math.PI * 2 * delta;
      render();
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [variant, count, color, size]);

  return (
    <canvas
      ref={canvasRef}
      role="img"
      aria-label={
        mode === "idle"
          ? "Assistant waiting"
          : mode === "thinking"
            ? "Assistant thinking"
            : "Assistant answering"
      }
      style={{ width: size, height: size, display: "block" }}
    />
  );
}

About this effect

A status indicator for an assistant, where the shape carries the state: a tilted ring while it waits, a connected web while it works, a winding ribbon while it answers. What makes it read as one object rather than three separate animations is that the point count never changes — every form is the same points rearranged, matched nearest-neighbour to their new positions, so nothing is created or destroyed and the orb becomes the next shape rather than dissolving into it. Rotation speed carries the state too: a quarter speed while idle, full speed once it is working.

AI assistant stateVoice agent listeningLong analysis runningCompanion presence

Related effects