# Building Procedural Motion for the Web: Geometry, Particles, and Deterministic Canvas Animation

> Source: <https://dev.to/bimotalk/building-procedural-motion-for-the-web-geometry-particles-and-deterministic-canvas-animation-1d66>
> Published: 2026-09-22 02:19:19+00:00

I’ve always liked a particular kind of web animation: thin lines, geometric structures, particles, orbital motion, and visuals that feel somewhere between an engineering diagram and generative art.

The problem is that many animations on the web are distributed as finished assets.

A GIF.

A video.

A Lottie file.

Or a component with dozens of hard-coded constants buried somewhere inside it.

While building [Limot](https://limot.dev/), I started thinking about animation differently:

What if an animation was not an asset, but a function?

Something you could parameterize, reproduce, export, and reuse.

That idea eventually became the basic architecture behind the animations I’ve been building.

In this post, I want to break down some of the techniques behind that approach.

A useful mental model for procedural animation is surprisingly simple:

```
frame = render(time, parameters)
```

Instead of thinking in terms of keyframes, think of the entire visual state as something that can be calculated from:

```
{
  time,
  width,
  height,
  parameters
}
```

For example:

```
function render(ctx, time, config) {
  const {
    speed,
    particleCount,
    particleSize,
    scale
  } = config;

  const t = time * speed;

  // calculate geometry
  // update positions
  // draw frame
}
```

Then the browser preview becomes just one way of driving that renderer:

```
function loop(timestamp) {
  ctx.clearRect(0, 0, width, height);

  render(ctx, timestamp / 1000, config);

  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);
```

This separation becomes extremely useful later.

The renderer doesn’t need to know whether the frame is being:

The renderer just renders.

That sounds obvious, but it changes how you design the entire animation system.

One thing I learned pretty quickly is that complex-looking motion often starts with surprisingly simple geometry.

Take a sphere made from particles.

A naive solution would generate random points on a sphere.

The problem is that random spherical coordinates can produce visible clustering.

Instead, you can use a **Fibonacci sphere**.

One implementation looks roughly like this:

``` js
function fibonacciSphere(count, radius = 1) {
  const points = [];
  const goldenAngle = Math.PI * (3 - Math.sqrt(5));

  for (let i = 0; i < count; i++) {
    const y = 1 - (i / (count - 1)) * 2;

    const r = Math.sqrt(1 - y * y);
    const theta = goldenAngle * i;

    const x = Math.cos(theta) * r;
    const z = Math.sin(theta) * r;

    points.push({
      x: x * radius,
      y: y * radius,
      z: z * radius
    });
  }

  return points;
}
```

This produces a much more even distribution.

Now you have something useful before animation even begins:

```
3D points
    ↓
rotation
    ↓
perspective projection
    ↓
Canvas coordinates
```

Once the geometry is stable, rotation becomes relatively trivial.

A point can be rotated around the Y axis using:

``` js
function rotateY(p, angle) {
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);

  return {
    x: p.x * cos - p.z * sin,
    y: p.y,
    z: p.x * sin + p.z * cos
  };
}
```

Then project it onto a 2D canvas:

``` js
function project(p, cameraDistance = 4) {
  const perspective =
    cameraDistance / (cameraDistance - p.z);

  return {
    x: p.x * perspective,
    y: p.y * perspective,
    scale: perspective
  };
}
```

With just these pieces you can already create a convincing 3D particle object using a 2D Canvas.

One of the effects I experimented with in [Limot's Particle Sphere](https://limot.dev/effects/particlesphere/) uses this idea for a particle sphere containing thousands of points, combined with drag inertia and cursor interaction.

You don’t always need WebGL for 3D-looking effects.

Canvas 2D works surprisingly well when the geometry is simple enough.

A common pipeline looks like this:

```
generate 3D geometry
        ↓
transform / rotate
        ↓
calculate depth
        ↓
perspective projection
        ↓
sort by depth
        ↓
draw on Canvas
```

The important part is often **depth sorting**.

Imagine particles orbiting around a black hole.

If you render them in their original order, particles behind the object may accidentally appear in front of it.

Instead:

``` js
particles.sort((a, b) => a.z - b.z);
```

Then render from back to front.

You can also use depth to modify appearance:

``` js
const alpha = mapDepthToOpacity(p.z);
const size = baseSize * perspective;
```

This creates a surprisingly strong illusion of volume.

For the [Black Hole experiment in Limot](https://limot.dev/effects/blackhole/), the animation combines orbiting particles, simulated inward motion, perspective, and depth sorting around the central void.

The interesting part is not really the black hole itself.

It is how far you can push:

```
points + transforms + projection + sorting
```

before needing a full 3D engine.

Another interesting problem appears when particles follow curved paths.

Suppose you have a parametric curve:

```
function curve(t) {
  return {
    x: Math.cos(t),
    y: Math.sin(t) * 0.5
  };
}
```

You might animate a particle like this:

``` js
const position = curve(time);
```

But there is a subtle problem.

Equal changes in `t` do **not necessarily represent equal distances along the curve**.

The particle speeds up in some sections and slows down in others.

Sometimes that looks fine.

But for things like streamlines or field lines, you usually want constant apparent velocity.

One approach is to build an arc-length lookup table.

First sample the curve:

``` js
const samples = [];

let previous = curve(0);
let distance = 0;

for (let i = 0; i <= 500; i++) {
  const t = i / 500;
  const p = curve(t);

  if (i > 0) {
    distance += Math.hypot(
      p.x - previous.x,
      p.y - previous.y
    );
  }

  samples.push({
    t,
    distance
  });

  previous = p;
}
```

Now instead of asking:

```
where is t = 0.5?
```

you can ask:

```
where is 50% of the total curve length?
```

Then interpolate between neighboring samples.

This technique is useful for effects like magnetic field visualizations, where small particles need to flow smoothly along curved field lines instead of visibly accelerating around sections of the curve.

The [Magnetic Field effect in Limot](https://limot.dev/effects/magnetic-field/) uses this idea of arc-length-based particle flow.

Generative animation usually needs randomness.

But `Math.random()` creates a problem.

Reload the animation:

```
different particles
different composition
different motion
```

Capture a video:

```
different again
```

Generate a thumbnail:

```
different again
```

That makes reproducibility difficult.

A seeded pseudo-random generator solves this.

``` js
function mulberry32(seed) {
  return function () {
    let t = seed += 0x6D2B79F5;

    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);

    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
```

Now:

``` js
const random = mulberry32(12345);

const x = random();
const y = random();
```

will always produce the same sequence.

This is extremely useful for visual tools.

A configuration like:

```
{
  seed: 12345,
  particleCount: 4000,
  speed: 0.8
}
```

can represent an exact visual state.

The same configuration can be loaded tomorrow and still generate the same composition.

Several of the animations I’ve been working on use deterministic or seeded motion for exactly this reason. [Particle Breath](https://limot.dev/effects/particle-breath/), for example, is designed around deterministic standalone animation behavior.

Linear interpolation is everywhere:

```
value += (target - value) * 0.1;
```

It works.

But for physical interfaces, spring motion often feels much better.

The basic model is:

```
force =
  spring force
  +
  damping force
```

Or approximately:

``` js
const springForce =
  -stiffness * (position - target);

const dampingForce =
  -damping * velocity;

const acceleration =
  (springForce + dampingForce) / mass;

velocity += acceleration * dt;
position += velocity * dt;
```

Now you get parameters developers may recognize from animation libraries:

```
{
  stiffness: 200,
  damping: 20,
  mass: 1
}
```

This is useful when elements should feel mechanical rather than merely interpolated.

For example, I use spring-like motion in a [Particle Cube experiment](https://limot.dev/effects/particle-cube/) where individual layers rotate by quarter turns before snapping back onto the cube's discrete grid.

That combination is interesting:

```
continuous physics
       +
discrete geometry
```

The spring handles movement.

The grid defines the final valid state.

Once I started adding controls to animations, another architectural pattern became important.

Don't make this:

```
UI controls

and separately...

animation configuration
```

Make them two views of the same data.

``` js
const schema = {
  particleCount: {
    type: "number",
    min: 100,
    max: 10000,
    default: 3000
  },

  speed: {
    type: "number",
    min: 0,
    max: 5,
    default: 1
  },

  color: {
    type: "color",
    default: "#ffffff"
  }
};
```

From that schema you can generate:

```
control panel
     ↓
runtime config
     ↓
component props
     ↓
export configuration
```

So changing:

```
particleCount = 8000
```

is not just changing a slider.

It changes the configuration used by every representation of the animation.

This is how a visual experiment starts becoming a reusable tool.

In [Limot](https://limot.dev/), animation parameters such as density, speed, line width, scale, geometry, interaction strength, and colors can be exposed directly in the editor and reused by exported code.

There is another important consequence of treating animation as a function of time.

Your preview might run like this:

```
requestAnimationFrame(loop);
```

But an export should not depend on real elapsed browser time.

Suppose you're generating a 5-second animation at 60 FPS.

That is exactly:

``` js
const fps = 60;
const duration = 5;

const totalFrames = fps * duration;

for (let frame = 0; frame < totalFrames; frame++) {
  const time = frame / fps;

  render(ctx, time, config);

  // capture frame
}
```

Now rendering becomes deterministic.

Frame 147 will always represent:

```
147 / 60
```

seconds.

Whether the computer renders that frame in 2 ms or 200 ms doesn't matter.

This makes it possible to use the same visual model for both interactive previews and exported media.

That became an important design idea for Limot because the same animation can ultimately be used as live code or exported as a still or motion asset.

You can explore the available output options on the [Limot pricing page](https://limot.dev/pricing/).

Particle animations make performance mistakes very visible.

If you're rendering thousands of points every frame, a few habits help a lot.

Bad:

``` js
for (const particle of particles) {
  calculateBaseGeometry(particle);
  animateParticle(particle);
  drawParticle(particle);
}
```

Better:

``` js
const baseGeometry =
  calculateGeometryOnce();

function render(time) {
  transformGeometry(baseGeometry, time);
  draw();
}
```

Instead of:

``` js
const point = {
  x,
  y,
  z
};
```

thousands of times per frame, reuse objects or arrays where appropriate.

Typed arrays are especially useful for large particle systems:

``` js
const positions =
  new Float32Array(particleCount * 3);
```

High-DPI rendering usually needs something like:

``` js
const dpr = window.devicePixelRatio || 1;

canvas.width = width * dpr;
canvas.height = height * dpr;

canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;

ctx.scale(dpr, dpr);
```

Without it, thin geometric lines often look blurry.

With very high-DPR devices, you may even want to cap it:

``` js
const dpr =
  Math.min(window.devicePixelRatio || 1, 2);
```

There is always a trade-off between sharpness and fill rate.

That matters particularly for designs made almost entirely from 1px lines and tiny particles.

At first I was mostly making individual visual experiments.

A particle sphere.

A black hole.

Orbital typography.

Magnetic field lines.

A Fourier drawing machine.

But eventually I realized that the more interesting problem was not:

How do I make this animation?

It was:

How do I make an animation reusable?

That adds an entirely different set of constraints.

It should be:

```
parameterized
deterministic
interactive
responsive
exportable
portable
```

And ideally the same effect should work as:

```
a website background
a React component
a Vue component
a standalone JavaScript animation
a transparent image
a GIF
a video
```

That is what I've been exploring with [Limot](https://limot.dev/).

It has grown into a collection of geometric, line-based, and particle-based web animations with live controls and reusable outputs.

If you're into creative coding, generative interfaces, Canvas, or just unnecessarily complicated ways of drawing dots on a screen, you might enjoy playing with it:

You can also jump directly into a few of the examples mentioned in this article:

I'd also love to know which part deserves a deeper technical write-up next:

**particle systems, fake 3D projection, deterministic animation, or video export?**
