Three.js Particles That Morph Between Shapes in React

6 min read

TL;DR 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.

On this page
  1. Why geometry vertices make bad particles
  2. Sample every shape to the same count
  3. The component
  4. What I measured
  5. How many particles can the spring loop handle
  6. Things you can change
  7. A finished version
  8. FAQ
  9. How do I morph three.js particles between two models with different vertex counts?
  10. Why do my three.js particles clump at the poles of a sphere?
  11. How do I turn text into three.js particles in React?
  12. Do I need ssr: false to use three.js in Next.js?

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 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.

Particle Morph running live. 9,000 particles that spring between a sphere, a word and a torus knot.

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 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 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 has the props. For more on motion settings, see prefers-reduced-motion in React. Free pieces with full source are at /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.