← All primitives
ParticleField
3D6k GPU particles conjured into a glowing Fibonacci sphere with additive blending + bloom. Pass any palette to re-skin.
$
npx shadcn@latest add https://prism.icglabs.co/r/particle-field.jsonDependencies:three@react-three/fiber@react-three/postprocessing
View raw manifest →components/prism/ParticleField.tsx
"use client";
import { useMemo, useRef, useState } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { EffectComposer, Bloom } from "@react-three/postprocessing";
import * as THREE from "three";
import { useDeviceTier } from "@/lib/useDeviceTier";
import { AutoDpr, PauseWhenOffscreen } from "@/components/showcase/CanvasPerf";
/**
* ParticleField — a GPU particle system that "conjures" 6k points from a
* scattered cloud into a glowing Fibonacci sphere, then breathes and follows the
* pointer. Custom GLSL + additive blending + bloom. Adapted from Sky's SkyScene
* into a configurable primitive.
*
* Props let you re-skin it without touching the shader: pass any palette.
*/
export type ParticleFieldProps = {
count?: number;
/** 2–4 hex colors blended across the cloud. */
colors?: [string, string, ...string[]];
size?: number;
className?: string;
};
const VERT = /* glsl */ `
attribute vec3 aTarget;
attribute vec3 aRand;
attribute float aSeed;
attribute vec3 aColor;
uniform float uProgress;
uniform float uTime;
uniform float uSize;
uniform float uPixelRatio;
varying vec3 vColor;
varying float vAlpha;
void main() {
float pr = smoothstep(0.0, 1.0, uProgress);
vec3 pos = mix(aRand, aTarget, pr);
float d = (0.04 + 0.03 * aSeed) * pr;
pos.x += sin(uTime * 0.40 + aSeed * 6.2831) * d;
pos.y += cos(uTime * 0.35 + aSeed * 6.2831) * d;
pos.z += sin(uTime * 0.30 + aSeed * 3.1415) * d;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_Position = projectionMatrix * mv;
gl_PointSize = uSize * uPixelRatio * (1.0 / -mv.z);
vColor = aColor;
vAlpha = mix(0.10, 0.95, pr);
}
`;
const FRAG = /* glsl */ `
precision mediump float;
varying vec3 vColor;
varying float vAlpha;
void main() {
vec2 c = gl_PointCoord - 0.5;
float dd = dot(c, c);
if (dd > 0.25) discard;
float a = smoothstep(0.25, 0.0, dd) * vAlpha;
gl_FragColor = vec4(vColor, a);
}
`;
function Conjure({
count,
colors,
size,
reduced,
}: {
count: number;
colors: string[];
size: number;
reduced: boolean;
}) {
const pts = useRef<THREE.Points>(null);
const mat = useRef<THREE.ShaderMaterial>(null);
const t0 = useRef(0);
const spin = useRef(0);
const geometry = useMemo(() => {
const position = new Float32Array(count * 3);
const aTarget = new Float32Array(count * 3);
const aRand = new Float32Array(count * 3);
const aSeed = new Float32Array(count);
const aColor = new Float32Array(count * 3);
const palette = colors.map((c) => new THREE.Color(c));
const R = 1.6;
const golden = Math.PI * (1 + Math.sqrt(5));
for (let i = 0; i < count; i++) {
const tt = i / count;
const phi = Math.acos(1 - 2 * tt);
const theta = golden * i;
const x = R * Math.sin(phi) * Math.cos(theta);
const y = R * Math.cos(phi);
const z = R * Math.sin(phi) * Math.sin(theta);
aTarget[i * 3] = x;
aTarget[i * 3 + 1] = y;
aTarget[i * 3 + 2] = z;
position[i * 3] = x;
position[i * 3 + 1] = y;
position[i * 3 + 2] = z;
const rr = 4 + Math.random() * 6;
const rp = Math.acos(2 * Math.random() - 1);
const rt = Math.random() * Math.PI * 2;
aRand[i * 3] = rr * Math.sin(rp) * Math.cos(rt);
aRand[i * 3 + 1] = rr * Math.cos(rp);
aRand[i * 3 + 2] = rr * Math.sin(rp) * Math.sin(rt);
aSeed[i] = Math.random();
// blend across the palette by vertical position, with an accent sprinkle
const f = (y / R + 1) / 2;
const seg = f * (palette.length - 1);
const lo = Math.floor(seg);
const hi = Math.min(lo + 1, palette.length - 1);
const col = palette[lo].clone().lerp(palette[hi], seg - lo);
if (palette.length > 2 && Math.random() < 0.14) {
col.lerp(palette[palette.length - 1], 0.6);
}
aColor[i * 3] = col.r;
aColor[i * 3 + 1] = col.g;
aColor[i * 3 + 2] = col.b;
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(position, 3));
g.setAttribute("aTarget", new THREE.BufferAttribute(aTarget, 3));
g.setAttribute("aRand", new THREE.BufferAttribute(aRand, 3));
g.setAttribute("aSeed", new THREE.BufferAttribute(aSeed, 1));
g.setAttribute("aColor", new THREE.BufferAttribute(aColor, 3));
return g;
}, [count, colors]);
const uniforms = useMemo(
() => ({
uProgress: { value: 0 },
uTime: { value: 0 },
uSize: { value: size },
uPixelRatio: { value: 1 },
}),
[size]
);
useFrame((state, delta) => {
const m = mat.current;
if (m) {
if (t0.current === 0) t0.current = state.clock.elapsedTime;
const el = state.clock.elapsedTime - t0.current;
if (!reduced) m.uniforms.uTime.value = state.clock.elapsedTime;
m.uniforms.uProgress.value = reduced ? 1 : Math.min(1, el / 3.2);
m.uniforms.uPixelRatio.value = Math.min(state.gl.getPixelRatio(), 2);
}
const p = pts.current;
if (p && !reduced) {
spin.current += delta * 0.06;
p.rotation.y = spin.current + state.pointer.x * 0.35;
p.rotation.x = THREE.MathUtils.lerp(p.rotation.x, -state.pointer.y * 0.22, 0.06);
}
});
return (
<points ref={pts} geometry={geometry}>
<shaderMaterial
ref={mat}
uniforms={uniforms}
vertexShader={VERT}
fragmentShader={FRAG}
transparent
depthWrite={false}
blending={THREE.AdditiveBlending}
/>
</points>
);
}
export function ParticleField({
count = 6000,
colors = ["#5b7cff", "#1fd4e6", "#8b5cff", "#ff3d81"],
size = 18,
className,
}: ParticleFieldProps) {
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const q = useDeviceTier();
const [low, setLow] = useState(false);
const n = Math.max(800, Math.round(count * q.scale));
const bloomOn = q.bloom && !low;
return (
<div className={className} style={{ width: "100%", height: "100%" }}>
<Canvas
dpr={q.dpr}
gl={{ antialias: true, powerPreference: "high-performance" }}
camera={{ position: [0, 0, 5], fov: 50 }}
>
<Conjure count={n} colors={colors} size={size} reduced={reduced} />
<AutoDpr onLow={() => setLow(true)} />
<PauseWhenOffscreen />
{bloomOn && (
<EffectComposer>
<Bloom intensity={1.1} luminanceThreshold={0} luminanceSmoothing={0.25} mipmapBlur radius={0.65} />
</EffectComposer>
)}
</Canvas>
</div>
);
}