Prism
← All primitives

ParticleField

3D

6k 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.json
Dependencies: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>
  );
}
Live demo — read-only. Every section is a real, copyable primitive.