Globe Particles
Scope
- Apply only to a globe-like 3D particle visualization.
- Do not change full page layout, copy, or unrelated motion systems.
- Use for planetary, orbital, infrastructure, or synthesized data-globe effects.
- Keep the core neutral or white-hot and derive ring/glow accents from the design's primary color.
Visual Target
- Dense spherical core of luminous points.
- Thinner outer orbital ring or flattened disc around the sphere.
- Clear globe silhouette with tilt, depth, and layered particle density.
- Dark atmospheric background, restrained glow, clean structure, and subtle sci-fi depth.
- Premium and cinematic, not playful or noisy.
HTML And CSS
<div class="globe-particles-shell">
<canvas class="globe-particles-canvas" data-globe-particles></canvas>
</div>
.globe-particles-shell {
position: relative;
width: min(100%, 760px);
aspect-ratio: 1 / 1;
}
.globe-particles-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
pointer-events: none;
}
Particle Shader
Use circular shader points so particles stay crisp and luminous.
const globeParticleVertex = `
attribute float a_size;
attribute float a_layer;
uniform float u_time;
uniform float u_pointSize;
varying float v_layer;
varying float v_depth;
varying float v_falloff;
void main() {
vec3 pos = position;
float breathe = 1.0 + sin(u_time * 0.65 + a_layer * 4.0) * 0.012;
pos *= breathe;
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = u_pointSize * a_size * (1.0 / max(0.18, -mvPosition.z));
gl_Position = projectionMatrix * mvPosition;
v_layer = a_layer;
v_depth = smoothstep(-1.8, 1.8, pos.z);
v_falloff = smoothstep(2.45, 0.25, length(pos));
}
`;
const globeParticleFragment = `
precision highp float;
uniform vec3 u_coreColor;
uniform vec3 u_accentColor;
varying float v_layer;
varying float v_depth;
varying float v_falloff;
void main() {
vec2 uv = gl_PointCoord - 0.5;
float d = length(uv);
float alpha = smoothstep(0.5, 0.0, d);
alpha *= alpha;
vec3 color = mix(u_coreColor, u_accentColor, smoothstep(0.35, 1.0, v_layer));
color += vec3(1.0) * v_depth * 0.08;
color = mix(color * 0.42, color, clamp(v_falloff + v_layer * 0.28, 0.0, 1.0));
alpha *= mix(0.52, 1.0, clamp(v_falloff + v_layer * 0.24, 0.0, 1.0));
gl_FragColor = vec4(color, alpha);
}
`;
Three.js Recipe
import * as THREE from "three";
function hexToRgb01(hex) {
const clean = hex.replace("#", "").trim();
const value = clean.length === 3
? clean.split("").map((char) => char + char).join("")
: clean;
return new THREE.Color(
parseInt(value.slice(0, 2), 16) / 255,
parseInt(value.slice(2, 4), 16) / 255,
parseInt(value.slice(4, 6), 16) / 255
);
}
function buildGlobeParticleGeometry(options = {}) {
const sphereCount = options.sphereCount || 2600;
const ringCount = options.ringCount || 1300;
const radius = options.radius || 1.35;
const ringRadius = options.ringRadius || 2.05;
const ringThickness = options.ringThickness || 0.12;
const total = sphereCount + ringCount;
const positions = new Float32Array(total * 3);
const sizes = new Float32Array(total);
const layers = new Float32Array(total);
for (let i = 0; i < sphereCount; i++) {
const z = Math.random() * 2 - 1;
const theta = Math.random() * Math.PI * 2;
const r = radius * (0.58 + Math.pow(Math.random(), 0.42) * 0.42);
const root = Math.sqrt(1 - z * z);
const index = i * 3;
positions[index] = Math.cos(theta) * root * r;
positions[index + 1] = Math.sin(theta) * root * r;
positions[index + 2] = z * r;
sizes[i] = 0.72 + Math.random() * 0.72;
layers[i] = Math.random() * 0.28;
}
for (let i = 0; i < ringCount; i++) {
const pointIndex = sphereCount + i;
const angle = Math.random() * Math.PI * 2;
const r = ringRadius + (Math.random() - 0.5) * ringThickness;
const y = (Math.random() - 0.5) * ringThickness * 0.58;
const index = pointIndex * 3;
positions[index] = Math.cos(angle) * r;
positions[index + 1] = y;
positions[index + 2] = Math.sin(angle) * r;
sizes[pointIndex] = 0.62 + Math.random() * 0.58;
layers[pointIndex] = 0.72 + Math.random() * 0.28;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("a_size", new THREE.BufferAttribute(sizes, 1));
geometry.setAttribute("a_layer", new THREE.BufferAttribute(layers, 1));
return geometry;
}
function initGlobeParticles(canvas, options = {}) {
if (!canvas) return () => {};
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
});
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, options.maxDpr || 1.6));
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
camera.position.set(0, 0, options.cameraDistance || 5.6);
const accent = options.accentColor
? new THREE.Color(options.accentColor)
: hexToRgb01(getComputedStyle(document.documentElement).getPropertyValue("--brand-accent").trim() || "#8b5cf6");
const geometry = buildGlobeParticleGeometry(options);
const material = new THREE.ShaderMaterial({
vertexShader: globeParticleVertex,
fragmentShader: globeParticleFragment,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
u_time: { value: 0 },
u_pointSize: { value: options.pointSize || 18 },
u_coreColor: { value: new THREE.Color(options.coreColor || 0xf8fafc) },
u_accentColor: { value: accent },
},
});
const particles = new THREE.Points(geometry, material);
particles.rotation.x = options.tiltX ?? -0.42;
particles.rotation.z = options.tiltZ ?? 0.22;
scene.add(particles);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const pointer = new THREE.Vector2(0, 0);
let rafId = 0;
function resize() {
const width = Math.max(1, canvas.clientWidth);
const height = Math.max(1, canvas.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, options.maxDpr || 1.6));
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function handlePointerMove(event) {
const rect = canvas.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width - 0.5) * 2;
pointer.y = ((event.clientY - rect.top) / rect.height - 0.5) * 2;
}
function render(time = 0) {
const t = time * 0.001;
material.uniforms.u_time.value = t;
const mouseStrength = options.mouseStrength ?? 0.08;
const breath = reduceMotion ? 0 : Math.sin(t * 0.55) * 0.045;
particles.rotation.y = t * (options.rotationSpeed || 0.12);
particles.rotation.x = (options.tiltX ?? -0.42) + pointer.y * mouseStrength;
particles.rotation.z = (options.tiltZ ?? 0.22) + pointer.x * mouseStrength;
particles.scale.setScalar(1 + breath);
renderer.render(scene, camera);
if (!reduceMotion) rafId = requestAnimationFrame(render);
}
function handleResize() {
cancelAnimationFrame(rafId);
resize();
render();
}
resize();
render();
window.addEventListener("resize", handleResize);
window.addEventListener("pointermove", handlePointerMove);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", handleResize);
window.removeEventListener("pointermove", handlePointerMove);
geometry.dispose();
material.dispose();
renderer.dispose();
};
}
const cleanupGlobe = initGlobeParticles(document.querySelector("[data-globe-particles]"), {
sphereCount: 2600,
ringCount: 1300,
accentColor: "#8b5cf6",
radius: 1.35,
ringRadius: 2.05,
rotationSpeed: 0.12,
mouseStrength: 0.08,
});
Tuning Knobs
- Density: tune
sphereCount and ringCount separately.
- Scale: tune
radius, ringRadius, ringThickness, and cameraDistance.
- Color: keep
coreColor neutral; derive accentColor from the brand primary.
- Motion: tune
rotationSpeed, tiltX, tiltZ, mouseStrength, and breathing amplitude.
- Glow: tune
pointSize, additive blending, and particle count so the shape stays crisp.
- Performance: lower particle counts or cap
maxDpr before changing the visual structure.
Taste Rules
- The silhouette must read as a globe, not a loose starfield.
- The ring should feel orbital and tilted, not like a flat decorative underline.
- Use restrained glow; let density and depth create the premium feel.
- Keep mouse response gentle so the object drifts rather than swings.
- Put the globe over a dark background or inside a dark atmospheric shell.
Avoid
- Generic starfield noise with no spherical structure.
- Oversized particles or bloom that destroys the globe silhouette.
- Hardcoded accent colors when the design has a clear primary color.
- Wild cursor interaction or fast spinning.
- Dense fog that turns the object into a blurry blob.
Quick Checks
- Sphere and ring are distinct particle populations.
- Core reads mostly neutral or white-hot.
- Accent color appears on ring, highlights, or glow.
- Tilt reveals the ring and globe depth.
- Reduced motion renders a still or near-still object.
- Geometry, material, renderer, listeners, and RAF are cleaned up.
1---2name: globe-particles3description: Create a globe-like 3D particle visualization with a dense luminous spherical core and thinner orbital ring or flattened disc. Use when a design needs a premium planetary, orbital, synthesized data-globe effect rendered with real WebGL/Three.js particles, not generic starfields or full page layout changes.4---5
6# Globe Particles
7
8## Scope
9- Apply only to a globe-like 3D particle visualization.
10- Do not change full page layout, copy, or unrelated motion systems.
11- Use for planetary, orbital, infrastructure, or synthesized data-globe effects.
12- Keep the core neutral or white-hot and derive ring/glow accents from the design's primary color.
13
14## Visual Target
15- Dense spherical core of luminous points.
16- Thinner outer orbital ring or flattened disc around the sphere.
17- Clear globe silhouette with tilt, depth, and layered particle density.
18- Dark atmospheric background, restrained glow, clean structure, and subtle sci-fi depth.
19- Premium and cinematic, not playful or noisy.
20
21## HTML And CSS
22
23```html
24<div class="globe-particles-shell">
25 <canvas class="globe-particles-canvas" data-globe-particles></canvas>
26</div>
27```
28
29```css
30.globe-particles-shell {
31 position: relative;
32 width: min(100%, 760px);
33 aspect-ratio: 1 / 1;
34}
35
36.globe-particles-canvas {
37 position: absolute;
38 inset: 0;
39 width: 100%;
40 height: 100%;
41 display: block;
42 pointer-events: none;
43}
44```
45
46## Particle Shader
47Use circular shader points so particles stay crisp and luminous.
48
49```js
50const globeParticleVertex = `
51attribute float a_size;
52attribute float a_layer;
53
54uniform float u_time;
55uniform float u_pointSize;
56
57varying float v_layer;
58varying float v_depth;
59varying float v_falloff;
60
61void main() {
62 vec3 pos = position;
63 float breathe = 1.0 + sin(u_time * 0.65 + a_layer * 4.0) * 0.012;
64 pos *= breathe;
65
66 vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
67 gl_PointSize = u_pointSize * a_size * (1.0 / max(0.18, -mvPosition.z));
68 gl_Position = projectionMatrix * mvPosition;
69
70 v_layer = a_layer;
71 v_depth = smoothstep(-1.8, 1.8, pos.z);
72 v_falloff = smoothstep(2.45, 0.25, length(pos));
73}
74`;
75
76const globeParticleFragment = `
77precision highp float;
78
79uniform vec3 u_coreColor;
80uniform vec3 u_accentColor;
81
82varying float v_layer;
83varying float v_depth;
84varying float v_falloff;
85
86void main() {
87 vec2 uv = gl_PointCoord - 0.5;
88 float d = length(uv);
89 float alpha = smoothstep(0.5, 0.0, d);
90 alpha *= alpha;
91
92 vec3 color = mix(u_coreColor, u_accentColor, smoothstep(0.35, 1.0, v_layer));
93 color += vec3(1.0) * v_depth * 0.08;
94 color = mix(color * 0.42, color, clamp(v_falloff + v_layer * 0.28, 0.0, 1.0));
95 alpha *= mix(0.52, 1.0, clamp(v_falloff + v_layer * 0.24, 0.0, 1.0));
96
97 gl_FragColor = vec4(color, alpha);
98}
99`;
100```
101
102## Three.js Recipe
103
104```js
105import * as THREE from "three";
106
107function hexToRgb01(hex) {
108 const clean = hex.replace("#", "").trim();
109 const value = clean.length === 3
110 ? clean.split("").map((char) => char + char).join("")
111 : clean;
112
113 return new THREE.Color(
114 parseInt(value.slice(0, 2), 16) / 255,
115 parseInt(value.slice(2, 4), 16) / 255,
116 parseInt(value.slice(4, 6), 16) / 255
117 );
118}
119
120function buildGlobeParticleGeometry(options = {}) {
121 const sphereCount = options.sphereCount || 2600;
122 const ringCount = options.ringCount || 1300;
123 const radius = options.radius || 1.35;
124 const ringRadius = options.ringRadius || 2.05;
125 const ringThickness = options.ringThickness || 0.12;
126 const total = sphereCount + ringCount;
127
128 const positions = new Float32Array(total * 3);
129 const sizes = new Float32Array(total);
130 const layers = new Float32Array(total);
131
132 for (let i = 0; i < sphereCount; i++) {
133 const z = Math.random() * 2 - 1;
134 const theta = Math.random() * Math.PI * 2;
135 const r = radius * (0.58 + Math.pow(Math.random(), 0.42) * 0.42);
136 const root = Math.sqrt(1 - z * z);
137 const index = i * 3;
138
139 positions[index] = Math.cos(theta) * root * r;
140 positions[index + 1] = Math.sin(theta) * root * r;
141 positions[index + 2] = z * r;
142 sizes[i] = 0.72 + Math.random() * 0.72;
143 layers[i] = Math.random() * 0.28;
144 }
145
146 for (let i = 0; i < ringCount; i++) {
147 const pointIndex = sphereCount + i;
148 const angle = Math.random() * Math.PI * 2;
149 const r = ringRadius + (Math.random() - 0.5) * ringThickness;
150 const y = (Math.random() - 0.5) * ringThickness * 0.58;
151 const index = pointIndex * 3;
152
153 positions[index] = Math.cos(angle) * r;
154 positions[index + 1] = y;
155 positions[index + 2] = Math.sin(angle) * r;
156 sizes[pointIndex] = 0.62 + Math.random() * 0.58;
157 layers[pointIndex] = 0.72 + Math.random() * 0.28;
158 }
159
160 const geometry = new THREE.BufferGeometry();
161 geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
162 geometry.setAttribute("a_size", new THREE.BufferAttribute(sizes, 1));
163 geometry.setAttribute("a_layer", new THREE.BufferAttribute(layers, 1));
164 return geometry;
165}
166
167function initGlobeParticles(canvas, options = {}) {
168 if (!canvas) return () => {};
169
170 const renderer = new THREE.WebGLRenderer({
171 canvas,
172 antialias: true,
173 alpha: true,
174 });
175 renderer.setClearColor(0x000000, 0);
176 renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, options.maxDpr || 1.6));
177 renderer.outputColorSpace = THREE.SRGBColorSpace;
178
179 const scene = new THREE.Scene();
180 const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
181 camera.position.set(0, 0, options.cameraDistance || 5.6);
182
183 const accent = options.accentColor
184 ? new THREE.Color(options.accentColor)
185 : hexToRgb01(getComputedStyle(document.documentElement).getPropertyValue("--brand-accent").trim() || "#8b5cf6");
186
187 const geometry = buildGlobeParticleGeometry(options);
188 const material = new THREE.ShaderMaterial({
189 vertexShader: globeParticleVertex,
190 fragmentShader: globeParticleFragment,
191 transparent: true,
192 depthWrite: false,
193 blending: THREE.AdditiveBlending,
194 uniforms: {
195 u_time: { value: 0 },
196 u_pointSize: { value: options.pointSize || 18 },
197 u_coreColor: { value: new THREE.Color(options.coreColor || 0xf8fafc) },
198 u_accentColor: { value: accent },
199 },
200 });
201
202 const particles = new THREE.Points(geometry, material);
203 particles.rotation.x = options.tiltX ?? -0.42;
204 particles.rotation.z = options.tiltZ ?? 0.22;
205 scene.add(particles);
206
207 const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
208 const pointer = new THREE.Vector2(0, 0);
209 let rafId = 0;
210
211 function resize() {
212 const width = Math.max(1, canvas.clientWidth);
213 const height = Math.max(1, canvas.clientHeight);
214 renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, options.maxDpr || 1.6));
215 renderer.setSize(width, height, false);
216 camera.aspect = width / height;
217 camera.updateProjectionMatrix();
218 }
219
220 function handlePointerMove(event) {
221 const rect = canvas.getBoundingClientRect();
222 pointer.x = ((event.clientX - rect.left) / rect.width - 0.5) * 2;
223 pointer.y = ((event.clientY - rect.top) / rect.height - 0.5) * 2;
224 }
225
226 function render(time = 0) {
227 const t = time * 0.001;
228 material.uniforms.u_time.value = t;
229
230 const mouseStrength = options.mouseStrength ?? 0.08;
231 const breath = reduceMotion ? 0 : Math.sin(t * 0.55) * 0.045;
232 particles.rotation.y = t * (options.rotationSpeed || 0.12);
233 particles.rotation.x = (options.tiltX ?? -0.42) + pointer.y * mouseStrength;
234 particles.rotation.z = (options.tiltZ ?? 0.22) + pointer.x * mouseStrength;
235 particles.scale.setScalar(1 + breath);
236
237 renderer.render(scene, camera);
238 if (!reduceMotion) rafId = requestAnimationFrame(render);
239 }
240
241 function handleResize() {
242 cancelAnimationFrame(rafId);
243 resize();
244 render();
245 }
246
247 resize();
248 render();
249 window.addEventListener("resize", handleResize);
250 window.addEventListener("pointermove", handlePointerMove);
251
252 return () => {
253 cancelAnimationFrame(rafId);
254 window.removeEventListener("resize", handleResize);
255 window.removeEventListener("pointermove", handlePointerMove);
256 geometry.dispose();
257 material.dispose();
258 renderer.dispose();
259 };
260}
261
262const cleanupGlobe = initGlobeParticles(document.querySelector("[data-globe-particles]"), {
263 sphereCount: 2600,
264 ringCount: 1300,
265 accentColor: "#8b5cf6",
266 radius: 1.35,
267 ringRadius: 2.05,
268 rotationSpeed: 0.12,
269 mouseStrength: 0.08,
270});
271```
272
273## Tuning Knobs
274- Density: tune `sphereCount` and `ringCount` separately.
275- Scale: tune `radius`, `ringRadius`, `ringThickness`, and `cameraDistance`.
276- Color: keep `coreColor` neutral; derive `accentColor` from the brand primary.
277- Motion: tune `rotationSpeed`, `tiltX`, `tiltZ`, `mouseStrength`, and breathing amplitude.
278- Glow: tune `pointSize`, additive blending, and particle count so the shape stays crisp.
279- Performance: lower particle counts or cap `maxDpr` before changing the visual structure.
280
281## Taste Rules
282- The silhouette must read as a globe, not a loose starfield.
283- The ring should feel orbital and tilted, not like a flat decorative underline.
284- Use restrained glow; let density and depth create the premium feel.
285- Keep mouse response gentle so the object drifts rather than swings.
286- Put the globe over a dark background or inside a dark atmospheric shell.
287
288## Avoid
289- Generic starfield noise with no spherical structure.
290- Oversized particles or bloom that destroys the globe silhouette.
291- Hardcoded accent colors when the design has a clear primary color.
292- Wild cursor interaction or fast spinning.
293- Dense fog that turns the object into a blurry blob.
294
295## Quick Checks
296- Sphere and ring are distinct particle populations.
297- Core reads mostly neutral or white-hot.
298- Accent color appears on ring, highlights, or glow.
299- Tilt reveals the ring and globe depth.
300- Reduced motion renders a still or near-still object.
301- Geometry, material, renderer, listeners, and RAF are cleaned up.