I needed a loader for an AI "thinking" state, and every spinner I tried looked like every other spinner. What I wanted was one object that could change shape as the state changed: a calm sphere while listening, a pulsing ring while thinking, a spiky surface while speaking.
So I built Orbs: ten dot-formation animations that can all morph into each other. It's one file, about 5 KB gzipped, with no dependencies and no build step.
This post walks through how it works. No WebGL, no three.js, just the 2D canvas API and some maths.
The one rule: every shape is a list of n points
The whole library rests on one constraint. A shape is a function that takes a number n and returns exactly n points inside the unit sphere.
Orb.define('sphere', {
build(n) { return { pts: fibonacciSphere(n) }; }
});
Because every shape returns the same number of points, morphing is just moving point i of shape A to point i of shape B. There's no matching, no resampling at runtime, no special case for "cube to galaxy". Any shape can go to any other.
The sphere uses a Fibonacci lattice, which spreads dots evenly without the clumping you get at the poles with latitude and longitude:
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
function fibonacciSphere(n) {
const pts = [];
for (let i = 0; i < n; i++) {
const y = 1 - (i / (n - 1)) * 2; // top to bottom
const r = Math.sqrt(1 - y * y); // radius at that height
const t = GOLDEN * i; // golden-angle step
pts.push([Math.cos(t) * r, y, Math.sin(t) * r]);
}
return pts;
}
Shapes that need randomness, like the galaxy arms or the k-means clusters, use a small seeded random number generator. That way a shape looks the same on every page load instead of reshuffling each time.
Shapes with fewer "natural" points
Some shapes don't naturally have n points. A cube has 8 corners and 12 edges. A constellation might have 20 stars.
The trick is to let dots double up. The cube spreads the same number of dots along each edge, then stacks any spare dots on the corners. The constellation stacks several dots on each star, which also makes the stars look brighter. Nobody can see that three dots share a position, but the morph code still has its n points to move.
From 3D to the screen
Each frame, every point goes through the same small projection: spin around the Y axis, tilt around the X axis, then apply a little perspective.
function project(p, out) {
let x = p[0] * cosA - p[2] * sinA; // spin
let z = p[0] * sinA + p[2] * cosA;
const y = p[1] * cosT - z * sinT; // tilt
z = p[1] * sinT + z * cosT;
const persp = 1 / (1 - z * 0.12); // gentle perspective
out[0] = cx + x * R * persp;
out[1] = cy + y * R * persp;
out[2] = (z + 1) / 2; // 0 = back, 1 = front
}
That last value, depth from 0 to 1, does a lot of work. Dots at the back are drawn smaller and fainter, dots at the front larger and brighter. That's enough to sell the 3D effect, and it means I never have to sort the dots back to front, which saves time on every frame.
Each shape can also set its own preferred tilt and spin speed. The torus sits almost face-on so it reads as an "o", and the helix turns faster side-on. When the shape changes, tilt and spin ease towards the new values instead of snapping.
Giving each shape a personality
Positions alone make every shape feel the same. What makes them distinct are a few optional hooks:
-
deformadds live motion. The wave pushes dots in and out along their radius with a couple of sine waves. The torus rotates every dot around its tube, so the surface appears to roll inside out. -
dashdraws a short radial stroke instead of a dot. That's howspikesand theticksloader ring work. -
sizeandalphacan be numbers or functions per dot. The galaxy makes its core dots bigger, and the constellation makes each star twinkle on its own phase. -
edgeslists pairs of points to join with lines: helix rungs, constellation links, cluster members tied to their centroid.
Here's the wave's deform, which is the whole "listening" effect:
deform(i, data, t, p, out) {
const k = 1
+ 0.17 * Math.sin(p[1] * 3.2 + t * 2.1) * Math.cos(3 * Math.atan2(p[2], p[0]) + t * 1.3)
+ 0.04 * Math.sin(t * 1.8);
out[0] = p[0] * k; out[1] = p[1] * k; out[2] = p[2] * k;
}
The morph itself
A plain linear move from A to B looks mechanical. Three small additions made it feel right.
1. A top-to-bottom cascade. Each dot gets a short delay based on where it's going. Dots heading for the top leave first, so the change sweeps down the shape like a wave.
const delay = (1 - (clamp(target[1], -1, 1) + 1) / 2) * 0.25;
const t = clamp((progress - delay) / 0.75, 0, 1);
2. Easing and a slight swell. Each dot uses a cubic ease in and out, and its position is pushed outwards by up to 8% in the middle of the flight. The shape seems to breathe out and settle back in, instead of shrinking through itself.
const e = easeInOut(t);
const swell = 1 + Math.sin(e * Math.PI) * 0.08;
cur[0] = (a[0] + (b[0] - a[0]) * e) * swell;
3. Cross-fading the style, not just the position. Dot size, opacity, dash length and line opacity are all blended between the old shape and the new one using the overall morph progress. So the spikes shrink back into dots as the sphere forms, and the constellation's lines fade out rather than vanishing.
Never jump
The bug that took longest to find: interrupting a morph made the orb jump. If you hovered while a wave was mid-ripple, the next morph started from the wave's base positions, not from where the dots actually were on screen.
The fix was to save each dot's final drawn position, motion included, every frame. A new morph always starts from exactly what is on screen right now. You can call morph() as often as you like and nothing ever snaps.
Being a good citizen on the page
An animation library that drains batteries isn't one anyone should ship, so Orbs is careful about when it runs:
- An
IntersectionObserverpauses an orb when it scrolls off screen, andvisibilitychangepauses it when the tab is hidden. - The canvas renders at device pixel ratio but caps it at 2. A 3x phone screen gets almost no visible benefit for more than double the pixels.
- Frame time is capped, so coming back to a tab doesn't throw every dot across the screen in one frame.
- With
prefers-reduced-motionturned on, each orb shows a still frame and morphs happen instantly. - Dot colour comes from the canvas's CSS
color, so you theme an orb the same way you theme text. AMutationObserverwatches for theme changes and follows the colour for a moment, so the dots fade along with whatever CSS transition your page uses.
Using it
<canvas data-orb="torus" style="width:200px; height:200px; color:#FF4400" aria-hidden="true"></canvas>
<script src="https://cdn.jsdelivr.net/gh/ShazMoghaddam/orbs@1.0.3/orb.min.js" defer></script>
Or drive it from JavaScript, which is what I use for AI states:
const orb = new Orb(canvas, { shape: 'wave', points: 90 });
orb.morph('wave'); // listening
orb.morph('ticks'); // thinking
orb.morph('spikes'); // speaking
You can also register your own shapes with Orb.define. As long as build(n) returns n points, your shape can morph to and from all the others.
What I learned
The biggest lesson was that one strict rule (every shape returns n points) made everything else simple. Most of the polish came from small details: the cascade delay, the 8% swell, starting each morph from what's actually drawn. None of them took many lines, but together they're the difference between "dots moving" and something that feels alive.
The code is MIT licensed and the full source is one readable file: github.com/ShazMoghaddam/orbs. There's a live demo with all ten shapes.
If you build a custom shape, I'd love to see it. And if you find Orbs useful, a star on the repo helps other developers find it.I needed a loader for an AI "thinking" state, and every spinner I tried looked like every other spinner. What I wanted was one object that could change shape as the state changed: a calm sphere while listening, a pulsing ring while thinking, a spiky surface while speaking.
So I built Orbs: ten dot-formation animations that can all morph into each other. It's one file, about 5 KB gzipped, with no dependencies and no build step.
This post walks through how it works. No WebGL, no three.js, just the 2D canvas API and some maths.
The one rule: every shape is a list of n points
The whole library rests on one constraint. A shape is a function that takes a number n and returns exactly n points inside the unit sphere.
Orb.define('sphere', {
build(n) { return { pts: fibonacciSphere(n) }; }
});
Because every shape returns the same number of points, morphing is just moving point i of shape A to point i of shape B. There's no matching, no resampling at runtime, no special case for "cube to galaxy". Any shape can go to any other.
The sphere uses a Fibonacci lattice, which spreads dots evenly without the clumping you get at the poles with latitude and longitude:
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
function fibonacciSphere(n) {
const pts = [];
for (let i = 0; i < n; i++) {
const y = 1 - (i / (n - 1)) * 2; // top to bottom
const r = Math.sqrt(1 - y * y); // radius at that height
const t = GOLDEN * i; // golden-angle step
pts.push([Math.cos(t) * r, y, Math.sin(t) * r]);
}
return pts;
}
Shapes that need randomness, like the galaxy arms or the k-means clusters, use a small seeded random number generator. That way a shape looks the same on every page load instead of reshuffling each time.
Shapes with fewer "natural" points
Some shapes don't naturally have n points. A cube has 8 corners and 12 edges. A constellation might have 20 stars.
The trick is to let dots double up. The cube spreads the same number of dots along each edge, then stacks any spare dots on the corners. The constellation stacks several dots on each star, which also makes the stars look brighter. Nobody can see that three dots share a position, but the morph code still has its n points to move.
From 3D to the screen
Each frame, every point goes through the same small projection: spin around the Y axis, tilt around the X axis, then apply a little perspective.
function project(p, out) {
let x = p[0] * cosA - p[2] * sinA; // spin
let z = p[0] * sinA + p[2] * cosA;
const y = p[1] * cosT - z * sinT; // tilt
z = p[1] * sinT + z * cosT;
const persp = 1 / (1 - z * 0.12); // gentle perspective
out[0] = cx + x * R * persp;
out[1] = cy + y * R * persp;
out[2] = (z + 1) / 2; // 0 = back, 1 = front
}
That last value, depth from 0 to 1, does a lot of work. Dots at the back are drawn smaller and fainter, dots at the front larger and brighter. That's enough to sell the 3D effect, and it means I never have to sort the dots back to front, which saves time on every frame.
Each shape can also set its own preferred tilt and spin speed. The torus sits almost face-on so it reads as an "o", and the helix turns faster side-on. When the shape changes, tilt and spin ease towards the new values instead of snapping.
Giving each shape a personality
Positions alone make every shape feel the same. What makes them distinct are a few optional hooks:
-
deformadds live motion. The wave pushes dots in and out along their radius with a couple of sine waves. The torus rotates every dot around its tube, so the surface appears to roll inside out. -
dashdraws a short radial stroke instead of a dot. That's howspikesand theticksloader ring work. -
sizeandalphacan be numbers or functions per dot. The galaxy makes its core dots bigger, and the constellation makes each star twinkle on its own phase. -
edgeslists pairs of points to join with lines: helix rungs, constellation links, cluster members tied to their centroid.
Here's the wave's deform, which is the whole "listening" effect:
deform(i, data, t, p, out) {
const k = 1
+ 0.17 * Math.sin(p[1] * 3.2 + t * 2.1) * Math.cos(3 * Math.atan2(p[2], p[0]) + t * 1.3)
+ 0.04 * Math.sin(t * 1.8);
out[0] = p[0] * k; out[1] = p[1] * k; out[2] = p[2] * k;
}
The morph itself
A plain linear move from A to B looks mechanical. Three small additions made it feel right.
1. A top-to-bottom cascade. Each dot gets a short delay based on where it's going. Dots heading for the top leave first, so the change sweeps down the shape like a wave.
const delay = (1 - (clamp(target[1], -1, 1) + 1) / 2) * 0.25;
const t = clamp((progress - delay) / 0.75, 0, 1);
2. Easing and a slight swell. Each dot uses a cubic ease in and out, and its position is pushed outwards by up to 8% in the middle of the flight. The shape seems to breathe out and settle back in, instead of shrinking through itself.
const e = easeInOut(t);
const swell = 1 + Math.sin(e * Math.PI) * 0.08;
cur[0] = (a[0] + (b[0] - a[0]) * e) * swell;
3. Cross-fading the style, not just the position. Dot size, opacity, dash length and line opacity are all blended between the old shape and the new one using the overall morph progress. So the spikes shrink back into dots as the sphere forms, and the constellation's lines fade out rather than vanishing.
Never jump
The bug that took longest to find: interrupting a morph made the orb jump. If you hovered while a wave was mid-ripple, the next morph started from the wave's base positions, not from where the dots actually were on screen.
The fix was to save each dot's final drawn position, motion included, every frame. A new morph always starts from exactly what is on screen right now. You can call morph() as often as you like and nothing ever snaps.
Being a good citizen on the page
An animation library that drains batteries isn't one anyone should ship, so Orbs is careful about when it runs:
- An
IntersectionObserverpauses an orb when it scrolls off screen, andvisibilitychangepauses it when the tab is hidden. - The canvas renders at device pixel ratio but caps it at 2. A 3x phone screen gets almost no visible benefit for more than double the pixels.
- Frame time is capped, so coming back to a tab doesn't throw every dot across the screen in one frame.
- With
prefers-reduced-motionturned on, each orb shows a still frame and morphs happen instantly. - Dot colour comes from the canvas's CSS
color, so you theme an orb the same way you theme text. AMutationObserverwatches for theme changes and follows the colour for a moment, so the dots fade along with whatever CSS transition your page uses.
Using it
<canvas data-orb="torus" style="width:200px; height:200px; color:#FF4400" aria-hidden="true"></canvas>
<script src="https://cdn.jsdelivr.net/gh/ShazMoghaddam/orbs@1.0.3/orb.min.js" defer></script>
Or drive it from JavaScript, which is what I use for AI states:
const orb = new Orb(canvas, { shape: 'wave', points: 90 });
orb.morph('wave'); // listening
orb.morph('ticks'); // thinking
orb.morph('spikes'); // speaking
You can also register your own shapes with Orb.define. As long as build(n) returns n points, your shape can morph to and from all the others.
What I learned
The biggest lesson was that one strict rule (every shape returns n points) made everything else simple. Most of the polish came from small details: the cascade delay, the 8% swell, starting each morph from what's actually drawn. None of them took many lines, but together they're the difference between "dots moving" and something that feels alive.
The code is MIT licensed and the full source is one readable file: github.com/ShazMoghaddam/orbs. There's a live demo with all ten shapes.
If you build a custom shape, I'd love to see it. And if you find Orbs useful, a star on the repo helps other developers find it.

Top comments (0)