Three.js particle sphere: a free React component
components/ParticleSphere.tsx
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
type Props = { count?: number; radius?: number; color?: string; size?: number; className?: string };
// Points spread evenly over a sphere: a Fibonacci (golden-angle) spiral.
// Each point sits at its own height, so there are no clumps at the poles.
export function fibonacciSphere(count: number, radius: number) {
const a = new Float32Array(count * 3);
const golden = Math.PI * (3 - Math.sqrt(5)); // about 2.39996 rad
for (let i = 0; i < count; i++) {
const y = 1 - (i / (count - 1)) * 2; // 1 at the top, -1 at the bottom
const r = Math.sqrt(1 - y * y);
const t = i * golden;
a[i * 3] = Math.cos(t) * r * radius;
a[i * 3 + 1] = y * radius;
a[i * 3 + 2] = Math.sin(t) * r * radius;
}
return a;
}
export default function ParticleSphere({
count = 4000,
radius = 2.6,
color = "#C6FF3D",
size = 0.06,
className = "",
}: Props) {
const host = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = host.current!;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(el.clientWidth, el.clientHeight);
el.append(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, el.clientWidth / el.clientHeight, 0.1, 100);
camera.position.z = 9;
// PointsMaterial draws squares; a small white circle as the map makes round dots
const dot = document.createElement("canvas");
dot.width = dot.height = 32;
const g = dot.getContext("2d")!;
g.beginPath();
g.arc(16, 16, 15, 0, Math.PI * 2);
g.fillStyle = "#fff";
g.fill();
const map = new THREE.CanvasTexture(dot);
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(fibonacciSphere(count, radius), 3));
const mat = new THREE.PointsMaterial({
color, size, map, transparent: true, alphaTest: 0.5, depthWrite: false,
blending: THREE.AdditiveBlending, // overlapping dots glow
});
const points = new THREE.Points(geo, mat);
points.rotation.x = 0.35; // tilt so the spiral reads as a globe
scene.add(points);
const reduced = matchMedia("(prefers-reduced-motion: reduce)");
let raf = 0;
let onScreen = true;
const frame = (now: number) => {
raf = 0;
points.rotation.y = (now / 1000) * 0.25;
renderer.render(scene, camera);
loop();
};
const loop = () => {
if (raf) return;
if (reduced.matches) { renderer.render(scene, camera); return; } // still frame
if (onScreen && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => { cancelAnimationFrame(raf); raf = 0; };
const io = new IntersectionObserver(([e]) => {
onScreen = e.isIntersecting;
if (onScreen) loop(); else stop();
});
io.observe(el);
const onVis = () => (document.hidden ? stop() : loop());
document.addEventListener("visibilitychange", onVis);
const onResize = () => {
camera.aspect = el.clientWidth / el.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(el.clientWidth, el.clientHeight);
renderer.render(scene, camera);
};
addEventListener("resize", onResize);
loop();
return () => {
stop();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
removeEventListener("resize", onResize);
geo.dispose();
mat.dispose();
map.dispose();
renderer.dispose();
renderer.domElement.remove();
};
}, [count, radius, color, size]);
return <div ref={host} className={`h-full w-full ${className}`} />;
}
app/page.tsx
import ParticleSphere from "@/components/ParticleSphere";
export default function Page() {
return (
<section className="h-[70vh] bg-black">
<ParticleSphere count={4000} color="#8B6CFF" />
</section>
);
}