Thinking Orb
A cloud of points that changes shape to say whether it is waiting, thinking, or answering.
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.
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.