DEV Community

Cover image for How I built morphing dot animations with plain canvas (no libraries, 5 KB)
Shaz M
Shaz M

Posted on AI-assisted

How I built morphing dot animations with plain canvas (no libraries, 5 KB)

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.

Orbs morphing between shapes

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) }; }
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  • deform adds 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.
  • dash draws a short radial stroke instead of a dot. That's how spikes and the ticks loader ring work.
  • size and alpha can be numbers or functions per dot. The galaxy makes its core dots bigger, and the constellation makes each star twinkle on its own phase.
  • edges lists 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 IntersectionObserver pauses an orb when it scrolls off screen, and visibilitychange pauses 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-motion turned 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. A MutationObserver watches 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Orbs morphing between shapes

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) }; }
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  • deform adds 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.
  • dash draws a short radial stroke instead of a dot. That's how spikes and the ticks loader ring work.
  • size and alpha can be numbers or functions per dot. The galaxy makes its core dots bigger, and the constellation makes each star twinkle on its own phase.
  • edges lists 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 IntersectionObserver pauses an orb when it scrolls off screen, and visibilitychange pauses 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-motion turned 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. A MutationObserver watches 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)