# Three.js Particles That Morph Between Shapes in React

> Geometry vertices make bad particles: a 32x16 SphereGeometry has 561 vertices with one pole repeated 33 times, and a torus knot has 5,025. Sample every shape to the same count (Fibonacci sphere, MeshSurfaceSampler, canvas text), then spring each particle to the same index in the next list. Pause the loop off screen and stop it for Reduce Motion.

- URL: https://qodesign.dev/blog/threejs-particles-morph-between-shapes-react
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

You can't morph three.js particles straight from one geometry to another, because the geometries don't have the same number of points. A `SphereGeometry(2, 32, 16)` has 561 vertices. A `TorusKnotGeometry(1.3, 0.4, 200, 24)` has 5,025. Particle 600 has nowhere to go. The fix is to stop using geometry vertices as particles. Sample every shape down to the same count first, then let each particle spring from its point in one list to the same index in the next.

I build the [Particle Morph](/components/particle-morph) piece for qodesign, 9,000 particles that move between a sphere, a word and a torus knot. For this post I wrote a smaller version from scratch and tested it in a fresh Next.js 16.4.2 app with React 19.3.1, three 0.186.1 and Tailwind CSS 4.3.3. Every file below passed strict `tsc` and `next build`, and headless Chromium measured it in production.

## Why geometry vertices make bad particles

I counted the vertices three.js builds for a few common shapes:

- `SphereGeometry(2, 32, 16)`: 561 vertices, only 497 unique. The top pole alone is the same point 33 times.
- `BoxGeometry(2, 2, 2)`: 24 vertices for 8 corners.
- `IcosahedronGeometry(2, 3)`: 960 vertices, 162 unique.
- `TorusKnotGeometry(1.3, 0.4, 200, 24)`: 5,025 vertices.

So the counts don't match, and the points aren't spread evenly either. Seams and poles repeat the same position, which shows up as bright clumps once you use additive blending. A cube gives you 8 visible dots. It's also why "how can I morph objects with different number of vertices" is a question on the three.js forum.

## Sample every shape to the same count

The trick is a small set of helpers that each return a `Float32Array` of exactly `count` points:

- `sphere()` spreads points with a Fibonacci lattice, so the poles don't bunch up.
- `surface()` uses three's `MeshSurfaceSampler`, which picks points across a mesh's triangles weighted by their area. It works for any geometry, including a loaded model.
- `text()` draws the word on a 2D canvas, keeps the filled pixels and resamples them.
- `resample()` turns any list into exactly `count` points, with a little jitter so repeated picks don't stack.

`lib/shapes.ts`:

```ts
import * as THREE from "three";
import { MeshSurfaceSampler } from "three/addons/math/MeshSurfaceSampler.js";

export type PointList = Float32Array; // x, y, z, x, y, z, ...

// Any list of points -> exactly `count` points. Every shape ends up the same length,
// so particle i always has a target in the next shape.
export function resample(src: PointList, count: number, jitter = 0): PointList {
  const n = src.length / 3;
  const out = new Float32Array(count * 3);
  for (let i = 0; i < count; i++) {
    const j = Math.floor(Math.random() * n) * 3;
    out[i * 3] = src[j] + (Math.random() - 0.5) * jitter;
    out[i * 3 + 1] = src[j + 1] + (Math.random() - 0.5) * jitter;
    out[i * 3 + 2] = src[j + 2] + (Math.random() - 0.5) * jitter;
  }
  return out;
}

// Even spread over a sphere (Fibonacci lattice): no clumps at the poles.
export function sphere(count: number, radius = 2): PointList {
  const out = new Float32Array(count * 3);
  const golden = 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 a = i * golden;
    out[i * 3] = Math.cos(a) * r * radius;
    out[i * 3 + 1] = y * radius;
    out[i * 3 + 2] = Math.sin(a) * r * radius;
  }
  return out;
}

// Points spread over the surface of any mesh, weighted by triangle area.
export function surface(geometry: THREE.BufferGeometry, count: number): PointList {
  const mesh = new THREE.Mesh(geometry);
  const sampler = new MeshSurfaceSampler(mesh).build();
  const out = new Float32Array(count * 3);
  const p = new THREE.Vector3();
  for (let i = 0; i < count; i++) {
    sampler.sample(p);
    out[i * 3] = p.x;
    out[i * 3 + 1] = p.y;
    out[i * 3 + 2] = p.z;
  }
  geometry.dispose();
  return out;
}

// Draw the word on a 2D canvas, keep every filled pixel on a grid, then resample.
export function text(word: string, count: number, font = "700 160px system-ui, sans-serif"): PointList {
  const w = 1000;
  const h = 240;
  const canvas = document.createElement("canvas");
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext("2d", { willReadFrequently: true });
  if (!ctx) return sphere(count);
  ctx.fillStyle = "#fff";
  ctx.font = font;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText(word, w / 2, h / 2);
  const data = ctx.getImageData(0, 0, w, h).data;
  const pts: number[] = [];
  const step = 3;
  for (let y = 0; y < h; y += step) {
    for (let x = 0; x < w; x += step) {
      if (data[(y * w + x) * 4 + 3] > 128) pts.push((x - w / 2) / 110, -(y - h / 2) / 110, 0);
    }
  }
  if (pts.length === 0) return sphere(count);
  return resample(new Float32Array(pts), count, 0.03);
}
```

The torus knot is sampled at twice the count and then resampled down. Sampling straight to `count` works too. I kept the extra step so the same code path handles a model you load from a GLB, where you might want to cache one dense sample and pull different counts from it.

## The component

The morph is a spring per coordinate. Every frame, each value moves toward the matching value in the target shape, and damping stops it from overshooting forever. Because all three lists have the same length, index `i` always has a target, so there's no special case.

`components/ParticleMorph.tsx`:

```tsx
"use client";

import { useEffect, useRef } from "react";
import * as THREE from "three";
import { resample, sphere, surface, text } from "@/lib/shapes";

type Props = { word?: string; count?: number; className?: string };

export default function ParticleMorph({ word = "morph", count = 6000, className = "" }: Props) {
  const hostRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    // Every shape has exactly `count` points, so index i always has a target.
    const shapes = [
      sphere(count),
      text(word, count),
      resample(surface(new THREE.TorusKnotGeometry(1.3, 0.4, 200, 24), count * 2), count),
    ];
    let current = 0;

    const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
    renderer.setSize(host.clientWidth, host.clientHeight);
    host.appendChild(renderer.domElement); // canvas made here, so Strict Mode can't reuse a dead one

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(45, host.clientWidth / host.clientHeight, 0.1, 100);
    camera.position.z = 8;

    const positions = Float32Array.from(shapes[0]);
    const velocity = new Float32Array(count * 3);
    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
    const material = new THREE.PointsMaterial({
      size: 0.035,
      color: "#c6ff3d",
      transparent: true,
      opacity: 0.85,
      blending: THREE.AdditiveBlending,
      depthWrite: false,
    });
    const points = new THREE.Points(geometry, material);
    scene.add(points);

    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
    let visible = true;
    let raf = 0;

    const step = () => {
      raf = 0;
      const target = shapes[current];
      for (let i = 0; i < positions.length; i++) {
        velocity[i] += (target[i] - positions[i]) * 0.012; // spring toward the target
        velocity[i] *= 0.88; // damping
        positions[i] += velocity[i];
      }
      geometry.attributes.position.needsUpdate = true;
      points.rotation.y += current === 1 ? -points.rotation.y * 0.05 : 0.004;
      renderer.render(scene, camera);
      if (visible && !document.hidden && !reduce.matches) raf = requestAnimationFrame(step);
    };

    const drawStill = () => {
      positions.set(shapes[current]);
      velocity.fill(0);
      points.rotation.y = 0;
      geometry.attributes.position.needsUpdate = true;
      renderer.render(scene, camera);
    };

    const start = () => {
      if (reduce.matches) {
        cancelAnimationFrame(raf); // a loop that is already running must stop too
        raf = 0;
        return drawStill();
      }
      if (!raf && visible && !document.hidden) raf = requestAnimationFrame(step);
    };

    const onClick = () => {
      current = (current + 1) % shapes.length;
      start();
    };

    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      start();
    });
    io.observe(host);

    const onResize = () => {
      camera.aspect = host.clientWidth / host.clientHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(host.clientWidth, host.clientHeight);
      if (reduce.matches) drawStill();
    };
    const ro = new ResizeObserver(onResize);
    ro.observe(host);

    host.addEventListener("click", onClick);
    document.addEventListener("visibilitychange", start);
    reduce.addEventListener("change", start);
    start();

    return () => {
      cancelAnimationFrame(raf);
      io.disconnect();
      ro.disconnect();
      host.removeEventListener("click", onClick);
      document.removeEventListener("visibilitychange", start);
      reduce.removeEventListener("change", start);
      geometry.dispose();
      material.dispose();
      renderer.dispose();
      renderer.forceContextLoss();
      renderer.domElement.remove();
    };
  }, [word, count]);

  return <div ref={hostRef} className={`h-full w-full cursor-pointer ${className}`} />;
}
```

And a page that uses it. Give the parent a height, because the canvas fills its host.

`app/particles/page.tsx`:

```tsx
import ParticleMorph from "@/components/ParticleMorph";

export default function Page() {
  return (
    <main className="mx-auto max-w-5xl px-6 py-16">
      <h1 className="text-3xl font-semibold">Click the particles</h1>
      <div className="mt-8 h-[520px] rounded-2xl border border-white/10">
        <ParticleMorph word="hello" />
      </div>
    </main>
  );
}
```

You don't need `next/dynamic` with `ssr: false` here. Nothing touches `window` or WebGL until the effect runs, so the server renders an empty div and `next build` prerendered the page as static. If you'd rather keep three.js out of the first bundle entirely, the [three.js hero recipe](/react/threejs-hero-section-nextjs) shows the dynamic import route.

## What I measured

Headless Chromium with SwiftShader, so this is software rendering and the frame rates are a floor. I counted real `drawArrays` calls on the WebGL context, not `requestAnimationFrame` ticks.

- **On screen:** 60 frames per second.
- **Scrolled out of view:** 0. The IntersectionObserver stops the loop and nothing is drawn.
- **Hidden tab:** 0. I simulated this by overriding `document.hidden` and firing `visibilitychange`, so treat it as a check of the code path.
- **Reduce Motion:** 0 frames per second while idle, and exactly 1 draw per click. The shape swaps instantly.
- **40 remounts in a row:** 1 canvas left in the page and 0 "Too many active WebGL contexts" warnings, thanks to `renderer.dispose()` plus `forceContextLoss()` in cleanup.
- **No page errors** and nothing in the console.

My first version had a bug worth knowing about. Switching Reduce Motion on in the OS while the loop was running still drew 60 frames per second, because the running loop kept booking the next frame. The fix is two lines: cancel the frame in `start()` when Reduce Motion is on, and check `reduce.matches` before booking the next one. Both are in the component above.

## How many particles can the spring loop handle

The spring runs on the CPU, over a `Float32Array` with three numbers per particle. I timed only that loop in the same headless Chromium on a 4-core server:

- 6,000 particles: 0.08 ms per frame
- 9,000 particles: 0.13 ms per frame
- 30,000 particles: 0.31 ms per frame
- 100,000 particles: 1.1 ms per frame

A frame at 60 fps has 16.7 ms, so up to tens of thousands of particles the CPU spring is cheap and easy to tweak. Uploading the buffer and drawing it cost extra on top of that, and phones are slower than this server. Past about 100,000 particles, or if your main thread is already busy, move the morph into a vertex shader: send the start and target positions as two attributes and blend them with a `uProgress` uniform. You lose the per-particle spring feel unless you fake it in the shader, which is why I kept it on the CPU at this size.

## Things you can change

- **The word:** `text()` scales canvas pixels by 1/110, so a longer word gets wider. Shrink the font or raise the divisor if it leaves the frame.
- **The feel:** `0.012` is the spring strength and `0.88` the damping. A higher strength snaps faster, and lower damping makes it wobble longer.
- **Colour:** this uses one colour. For a gradient, add a `color` attribute and set `vertexColors: true` on the material.
- **Your own model:** load a GLB, take its geometry and pass it to `surface()`. The particle count stays the same whatever the model's vertex count is.

## A finished version

qodesign's [Particle Morph](/components/particle-morph) is the full piece: 9,000 particles with a colour gradient across the field, morphs between a sphere, your word and a torus knot, and a pointer that scatters the particles before they spring back. It comes as plain HTML and as a React component, with Reduce Motion and off-screen pausing already handled. The [React setup page](/react/particle-text-animation) has the props. For more on motion settings, see [prefers-reduced-motion in React](/blog/prefers-reduced-motion-react). Free pieces with full source are at [/free](/free).

## FAQ

### How do I morph three.js particles between two models with different vertex counts?

Don't use the model vertices as particles. Sample each model to the same number of points with `MeshSurfaceSampler`, store each result as a `Float32Array`, and animate one `Points` buffer toward whichever array is the current target. Index `i` then always has a matching point.

### Why do my three.js particles clump at the poles of a sphere?

`SphereGeometry` repeats vertices along its seam and at the poles. With 32 by 16 segments the top pole is the same point 33 times, so additive blending makes it glow. Use a Fibonacci lattice or `MeshSurfaceSampler` instead of the geometry's own vertices.

### How do I turn text into three.js particles in React?

Draw the text on a 2D canvas with `fillText`, read it with `getImageData`, and keep a point for every filled pixel on a small grid. Then resample that list to your particle count and use it as one of the morph targets.

### Do I need ssr: false to use three.js in Next.js?

Not if WebGL only runs inside `useEffect`. The server renders the empty host div and the effect builds the renderer in the browser. In my test `next build` prerendered the page as static with no errors. Use `next/dynamic` with `ssr: false` when you want three.js out of the initial JavaScript.
