About
One THREE.Points with two target attributes per point: where it is leaving from and where it is going. Each shape is a function that gives every point a target on the CPU (a Fibonacci shell, a torus, a filled heart curve, a cube's faces, two helices with rungs, three spiral arms), computed once at build. A change copies the reached shape into aFrom, the next into aTo and runs a 1.7 s ease; in the vertex shader each point's progress is offset by its own seed so the cloud pours, and a short lift out along the radius makes the middle of the change look like a burst. The pointer's position, taken into the cloud's own space, pushes nearby points away; colour is a three-stop ramp on height.
Use it when
- An AI, data or technology brand wants a centrepiece that can become its mark.
- A creative studio wants a hero that changes shape with its services.
- A product page wants a quiet, living object next to a headline.
Skip it when
- The page is light: the points are additive and vanish on white.
- The page already has a 3D centrepiece.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/particle-sphere - 3Use it:
import { ParticleSphere } from "@/components/vibebox/particle-sphere"; <div className="rounded-xl bg-[#05060b]"> <ParticleSphere className="mx-auto aspect-square w-full max-w-[520px]" shapes={["sphere", "heart", "helix"]} /> </div>
Code
3 filescomponents/vibebox/particle-sphere/index.tsx
/**
* VibeBox Motion · Particle Sphere
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
export { ParticleSphere, type ParticleShape, type ParticleSphereProps } from "./particle-sphere.client";
export type { ParticleSphereOptions } from "./particle-sphere.sculpture";components/vibebox/particle-sphere/particle-sphere.client.tsx
/**
* VibeBox Motion · Particle Sphere
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
"use client";
import { useCallback } from "react";
import { SculptureCanvas } from "@/lib/vibebox/sculpture-canvas";
import type { ParticleShape, ParticleSphereOptions } from "./particle-sphere.sculpture";
export type { ParticleShape };
export type ParticleSphereProps = ParticleSphereOptions & {
/** Accessible name. */
label?: string;
/** Show the name of the shape the cloud is in. */
caption?: boolean;
className?: string;
};
/* A dotted disc in CSS, until the cloud is ready and wherever WebGL is not. */
function DotPoster() {
return (
<span aria-hidden="true" className="absolute inset-0 grid place-items-center">
<span
className="aspect-square w-[58%] rounded-full opacity-90"
style={{
backgroundImage:
"radial-gradient(circle at 50% 50%, var(--spectrum-3) 0 1.4px, transparent 1.8px), radial-gradient(circle at 30% 30%, color-mix(in oklab, var(--spectrum-1) 60%, transparent), transparent 70%)",
backgroundSize: "9px 9px, 100% 100%",
maskImage: "radial-gradient(circle, #000 62%, transparent 63%)",
WebkitMaskImage: "radial-gradient(circle, #000 62%, transparent 63%)",
}}
/>
</span>
);
}
export function ParticleSphere({
label = "A cloud of points that turns into other shapes: a sphere, a ring, a heart, a cube, a helix and a galaxy",
caption = false,
className,
...options
}: ParticleSphereProps) {
const load = useCallback(() => import("./particle-sphere.sculpture").then((module) => module.createParticleSphere), []);
return <SculptureCanvas slug="particle-sphere" load={load} options={options} label={label} poster={<DotPoster />} status={caption} className={className} />;
}components/vibebox/particle-sphere/particle-sphere.sculpture.ts
/**
* VibeBox Motion · Particle Sphere
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import * as THREE from "three";
import { clamp01, damp, disposeObject, easeInOutCubic, easeOutCubic, spectrumAt } from "@/lib/vibebox/engine/three";
import type { Sculpture, SculptureFrame } from "@/lib/vibebox/engine/three";
/*
* A cloud of points that is a sphere until it is something else. Every shape is a set of target
* positions, one per point, computed once on the CPU; the vertex shader slides each point from
* where it was to where it goes, staggered so the cloud pours rather than slides, with a short
* lift out along the way. Points push away from the pointer, shimmer a little, and take their
* colour from height on a three-stop ramp (the page's spectrum unless `colors` is given).
*/
export const PARTICLE_SHAPES = ["sphere", "ring", "heart", "cube", "helix", "galaxy"] as const;
export type ParticleShape = (typeof PARTICLE_SHAPES)[number];
export type ParticleSphereOptions = {
/** The shapes to turn through, in order (default: all of them). */
shapes?: readonly ParticleShape[];
/** How many points (1000 to 16000; default 7000). */
count?: number;
/** Ramp colours, low to high (any CSS colours; default the page's spectrum). */
colors?: readonly [string, string, string];
/** Seconds each shape holds before the next (default 3.4). */
hold?: number;
};
const frac = (x: number) => x - Math.floor(x);
const R1 = 0.7548776662466927;
const R2 = 0.5698402909980532;
const hash = (n: number) => frac(Math.sin(n * 127.1 + 311.7) * 43758.5453);
/** Target positions for one shape: `count` points, roughly within a radius of 1. */
function shapeTargets(shape: ParticleShape, count: number): Float32Array {
const out = new Float32Array(count * 3);
const set = (i: number, x: number, y: number, z: number) => {
out[i * 3] = x;
out[i * 3 + 1] = y;
out[i * 3 + 2] = z;
};
for (let i = 0; i < count; i++) {
const u = frac(0.5 + i * R1);
const v = frac(0.5 + i * R2);
const w = hash(i + 0.5);
switch (shape) {
case "sphere": {
// A Fibonacci shell with a little depth.
const y = 1 - (i / (count - 1)) * 2;
const r = Math.sqrt(Math.max(0, 1 - y * y));
const a = i * Math.PI * (3 - Math.sqrt(5));
const rad = 0.92 + 0.08 * w;
set(i, Math.cos(a) * r * rad, y * rad, Math.sin(a) * r * rad);
break;
}
case "ring": {
const a = u * Math.PI * 2;
const b = v * Math.PI * 2;
const big = 0.72;
const small = 0.27 * (0.85 + 0.15 * w);
set(i, (big + small * Math.cos(b)) * Math.cos(a), small * Math.sin(b) * 1.1, (big + small * Math.cos(b)) * Math.sin(a));
break;
}
case "heart": {
// The classic heart curve, filled by scaling toward the centre and given depth.
const t = u * Math.PI * 2;
const hx = 16 * Math.sin(t) ** 3;
const hy = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t);
// Half the points trace the edge, half fill the inside, so it reads as a heart, not an outline.
const fill = v < 0.4 ? 1 : Math.sqrt((v - 0.4) / 0.6);
const depth = (w - 0.5) * 0.62 * Math.sqrt(Math.max(0, 1 - fill * fill * 0.6));
set(i, (hx / 17) * 0.98 * fill, (hy / 17 + 0.04) * 0.98 * fill, depth);
break;
}
case "cube": {
const face = Math.floor(w * 6) % 6;
const a = u * 2 - 1;
const b = v * 2 - 1;
const s = 0.68;
const p: [number, number, number][] = [
[s, a * s, b * s],
[-s, a * s, b * s],
[a * s, s, b * s],
[a * s, -s, b * s],
[a * s, b * s, s],
[a * s, b * s, -s],
];
const q = p[face]!;
set(i, q[0], q[1], q[2]);
break;
}
case "helix": {
// Two strands and the rungs between them.
const t = u * 2 - 1;
const turn = t * Math.PI * 3.2;
const rung = w > 0.78;
const strand = i % 2 === 0 ? 0 : Math.PI;
if (rung) {
const k = v * 2 - 1;
set(i, Math.cos(turn) * 0.5 * k, t * 0.95, Math.sin(turn) * 0.5 * k);
} else {
set(i, Math.cos(turn + strand) * 0.5, t * 0.95, Math.sin(turn + strand) * 0.5);
}
break;
}
case "galaxy": {
// A flat spiral: arms wound by radius, a bright dense core.
const arm = i % 3;
const r = Math.pow(u, 0.75) * 1.0;
const a = r * 5.2 + (arm * Math.PI * 2) / 3 + (v - 0.5) * 0.9;
set(i, Math.cos(a) * r, (w - 0.5) * 0.1 * (1 - r * 0.7), Math.sin(a) * r);
break;
}
}
}
return out;
}
const vertex = /* glsl */ `
attribute vec3 aFrom;
attribute vec3 aTo;
attribute float aSeed;
uniform float uMix;
uniform float uBuild;
uniform float uTime;
uniform float uSize;
uniform float uScale;
uniform float uHover;
uniform vec3 uPointer;
uniform vec3 uC0;
uniform vec3 uC1;
uniform vec3 uC2;
varying vec3 vColor;
varying float vAlpha;
void main() {
// Each point leaves a little after or before its neighbours, so the cloud pours.
float k = clamp(uMix * 1.5 - aSeed * 0.5, 0.0, 1.0);
k = k * k * (3.0 - 2.0 * k);
vec3 p = mix(aFrom, aTo, k);
float travel = sin(k * 3.14159265);
p += normalize(p + vec3(0.0001)) * travel * (0.12 + 0.2 * aSeed);
p += vec3(sin(uTime * 0.9 + aSeed * 40.0), cos(uTime * 0.8 + aSeed * 31.0), sin(uTime * 0.7 + aSeed * 23.0)) * 0.012;
vec3 away = p - uPointer;
float dist = length(away);
p += normalize(away + vec3(0.0001)) * smoothstep(0.5, 0.0, dist) * uHover * 0.3;
p *= uBuild;
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mv;
gl_PointSize = uScale * uSize * (0.55 + 0.9 * aSeed) / max(0.2, -mv.z);
float t = clamp(p.y * 0.5 + 0.5 + (aSeed - 0.5) * 0.3, 0.0, 1.0);
vColor = t < 0.5 ? mix(uC0, uC1, t * 2.0) : mix(uC1, uC2, (t - 0.5) * 2.0);
vAlpha = 0.5 + 0.5 * aSeed;
}
`;
const fragment = /* glsl */ `
varying vec3 vColor;
varying float vAlpha;
void main() {
float d = length(gl_PointCoord - vec2(0.5));
float a = smoothstep(0.5, 0.05, d) * vAlpha;
if (a < 0.01) discard;
gl_FragColor = vec4(vColor * (0.7 + 0.8 * a), a);
}
`;
export function createParticleSphere(options: ParticleSphereOptions = {}): Sculpture {
const shapes = (options.shapes?.length ? [...options.shapes] : [...PARTICLE_SHAPES]).filter((shape) => PARTICLE_SHAPES.includes(shape));
if (!shapes.length) shapes.push("sphere");
const count = Math.round(Math.max(1000, Math.min(16000, options.count ?? 7000)));
const hold = options.hold ?? 3.4;
const MORPH = 1.7;
const targets = shapes.map((shape) => shapeTargets(shape, count));
const from = new Float32Array(targets[0]!);
const to = new Float32Array(targets[0]!);
const seeds = new Float32Array(count);
for (let i = 0; i < count; i++) seeds[i] = hash(i * 1.37 + 9.1);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(count * 3), 3));
const fromAttr = new THREE.BufferAttribute(from, 3);
const toAttr = new THREE.BufferAttribute(to, 3);
fromAttr.setUsage(THREE.DynamicDrawUsage);
toAttr.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute("aFrom", fromAttr);
geometry.setAttribute("aTo", toAttr);
geometry.setAttribute("aSeed", new THREE.BufferAttribute(seeds, 1));
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2.4);
const ramp = options.colors
? options.colors.map((color) => new THREE.Color(color))
: [spectrumAt(0.05, new THREE.Color()), spectrumAt(0.5, new THREE.Color()), spectrumAt(0.92, new THREE.Color())];
const material = new THREE.ShaderMaterial({
uniforms: {
uMix: { value: 1 },
uBuild: { value: 0 },
uTime: { value: 0 },
uSize: { value: 0.036 },
uScale: { value: 700 },
uHover: { value: 0 },
uPointer: { value: new THREE.Vector3(9, 9, 9) },
uC0: { value: ramp[0]! },
uC1: { value: ramp[1]! },
uC2: { value: ramp[2]! },
},
vertexShader: vertex,
fragmentShader: fragment,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false;
// Point size is in pixels: scale a world-space size by the drawing buffer's height and the lens.
const bufferSize = new THREE.Vector2();
points.onBeforeRender = (renderer, _scene, camera) => {
renderer.getDrawingBufferSize(bufferSize);
const fov = (camera as THREE.PerspectiveCamera).fov ?? 35;
material.uniforms.uScale!.value = bufferSize.y / (2 * Math.tan(THREE.MathUtils.degToRad(fov) / 2));
};
const object = new THREE.Group();
object.add(points);
object.scale.setScalar(1.6);
let index = 0;
let timer = 0;
let morph = 1; // 0..1 progress of the current change; 1 means at rest
let built = 0;
const local = new THREE.Vector3();
const startMorph = (next: number) => {
// Pick up from the shape just reached (the previous change has finished).
from.set(targets[index]!);
to.set(targets[next]!);
fromAttr.needsUpdate = true;
toAttr.needsUpdate = true;
index = next;
morph = 0;
timer = 0;
};
return {
object,
assembleSeconds: 1.4,
holdSeconds: 40,
assemble(progress) {
built = easeOutCubic(clamp01(progress));
material.uniforms.uBuild!.value = 0.2 + 0.8 * built;
points.rotation.y = (1 - built) * 1.6;
},
update(frame: SculptureFrame) {
const u = material.uniforms;
u.uTime!.value = frame.time;
if (frame.still) {
u.uMix!.value = 1;
u.uHover!.value = 0;
return;
}
timer += frame.delta;
if (morph < 1) {
morph = Math.min(1, morph + frame.delta / MORPH);
} else if (shapes.length > 1 && timer > hold * (frame.hover > 0.4 ? 0.45 : 1)) {
startMorph((index + 1) % shapes.length);
}
u.uMix!.value = easeInOutCubic(morph);
u.uHover!.value += (frame.hover - u.uHover!.value) * damp(6, frame.delta);
// The pointer, in the cloud's own space, for the push.
local.copy(frame.pointerWorld);
points.worldToLocal(local);
(u.uPointer!.value as THREE.Vector3).copy(local);
points.rotation.y += frame.delta * (0.16 + 0.3 * frame.hover);
},
status() {
return built > 0.9 ? shapes[index] ?? null : null;
},
dispose() {
disposeObject(object);
geometry.dispose();
material.dispose();
},
};
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| shapes | ParticleShape[] | ["sphere", "ring", "heart", "cube", "helix", "galaxy"] | The shapes to turn through, in order (one shape makes it a still cloud). |
| count | number | 7000 | How many points (1000 to 16000). |
| colors | [string, string, string] | — | Ramp colours from low to high; the page's spectrum when omitted. |
| hold | number | 3.4 | Seconds each shape holds before the next. |
| caption | boolean | false | Shows the name of the current shape. |
| label | string | — | Accessible name. |
| className | string | — | Merged with cn(); give it a size, about square, on a dark background. |
Accessibility and motion
- Keyboard
- Not focusable; the cloud is an illustration.
- Screen readers
- An image named by
label;captionalso shows the current shape's name as live text. - Touch
- A finger on it pushes the points away and quickens the change; scrolling is never blocked.
- Reduced motion
- One still sphere of points; it does not turn, shimmer or change shape.
Edge cases and limits
- The points are additive: on a light page they disappear; give it a dark card.
- More than 10000 points on a phone costs frame time for no visible gain.
- One shape in
shapesmakes it a still cloud that only shimmers.
Related

Morph Sphere
FreeA liquid sphere that melts into the things you sell: fifteen shapes, four skins, tap for the next.
3D · WebGL2

Brochure
FreeA 3D brochure whose paper bends: click a page, drag a corner or press the arrow keys to turn it.
3D · three.js

Coin
FreeA struck coin with a reeded edge, your mark and words on its face; hold the pointer on it and it turns over.
3D · three.js