Skip to content

FreeIn reviewv1.0.0

Particle Sphere

A cloud of points that is a sphere until it is a ring, a heart, a cube, a helix or a galaxy, and shies from the pointer.
npx shadcn@latest add @vibebox/particle-sphere

Controls

3.4
7000
<ParticleSphere />

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

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/particle-sphere
  3. 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 files
components/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

PropTypeDefaultDescription
shapesParticleShape[]["sphere", "ring", "heart", "cube", "helix", "galaxy"]The shapes to turn through, in order (one shape makes it a still cloud).
countnumber7000How many points (1000 to 16000).
colors[string, string, string]—Ramp colours from low to high; the page's spectrum when omitted.
holdnumber3.4Seconds each shape holds before the next.
captionbooleanfalseShows the name of the current shape.
labelstring—Accessible name.
classNamestring—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; caption also 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 shapes makes it a still cloud that only shimmers.
  • A liquid sphere of cyan, violet and magenta melting into a heart

    A liquid sphere that melts into the things you sell: fifteen shapes, four skins, tap for the next.

    3D · WebGL2

  • An open 3D brochure on a desk with a page turning and its corner lifted

    A 3D brochure whose paper bends: click a page, drag a corner or press the arrow keys to turn it.

    3D · three.js

  • A gold coin with a V in the middle and words around its rim, turning on its edge

    Coin

    Free

    A 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

↑ ↓ to move↵ to openesc to close