"use client";
import * as React from "react";
export interface ParticleSphereProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/** Colour of particles facing the viewer. */
color?: string;
/** Colour particles fade toward on the far side. */
secondaryColor?: string;
/** Number of particles before adaptive reduction. */
density?: number;
/** Particle radius in CSS px (front-most particles). */
size?: number;
/** Rotation speed multiplier (0 stops the spin). */
speed?: number;
/** Sphere radius as a fraction of the smaller container side. */
radius?: number;
/** How particles react to the pointer. */
interaction?: "repel" | "attract" | "none";
/** Tilt the sphere toward the pointer. */
tilt?: boolean;
/** Reduce particles on small screens and when the frame rate drops. */
adaptive?: boolean;
/** Soft halo behind the sphere. */
glow?: boolean;
/** Accessible label. Without one the canvas is treated as decorative. */
label?: string;
}
type RGB = [number, number, number];
function parseHex(hex: string, fallback: RGB): RGB {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return fallback;
const h = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1];
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
/** Evenly distributed unit-sphere points (Fibonacci lattice), packed as [x, y, z, …]. */
function fibonacciSphere(count: number) {
const points = new Float32Array(count * 3);
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < count; i++) {
const y = 1 - ((i + 0.5) * 2) / count;
const r = Math.sqrt(1 - y * y);
const theta = i * golden;
points[i * 3] = Math.cos(theta) * r;
points[i * 3 + 1] = y;
points[i * 3 + 2] = Math.sin(theta) * r;
}
return points;
}
const BUCKETS = 7;
const FOV = 3.2; // camera distance in sphere radii
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/**
* A rotating sphere of particles drawn on a 2D canvas. Points are projected with perspective,
* depth-shaded in batches, and displaced around the pointer. The loop pauses offscreen and in
* background tabs, and a single static frame is drawn for reduced motion.
*/
export function ParticleSphere({
color = "#FF7448",
secondaryColor = "#B6F3D1",
density = 900,
size = 1.6,
speed = 1,
radius = 0.36,
interaction = "repel",
tilt = true,
adaptive = true,
glow = true,
label,
className = "",
...props
}: ParticleSphereProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
const front = parseHex(color, [255, 116, 72]);
const back = parseHex(secondaryColor, [182, 243, 209]);
// Pre-computed fill styles per depth bucket (far → near).
const fills = Array.from({ length: BUCKETS }, (_, b) => {
const t = b / (BUCKETS - 1);
const mix = (i: number) => Math.round(back[i] + (front[i] - back[i]) * t);
const alpha = 0.14 + 0.86 * Math.pow(t, 1.4);
return `rgba(${mix(0)}, ${mix(1)}, ${mix(2)}, ${alpha.toFixed(3)})`;
});
const target = Math.max(20, Math.round(density));
let count = target;
let points = fibonacciSphere(count);
let sx = new Float32Array(count);
let sy = new Float32Array(count);
let sr = new Float32Array(count);
let bucket = new Uint8Array(count);
const setCount = (next: number) => {
count = next;
points = fibonacciSphere(count);
sx = new Float32Array(count);
sy = new Float32Array(count);
sr = new Float32Array(count);
bucket = new Uint8Array(count);
};
let width = 0;
let height = 0;
let angle = 0.6;
let tiltX = -0.25;
let tiltY = 0;
const pointer = { x: 0, y: 0, inside: false, strength: 0, tx: 0, ty: 0 };
let frame = 0;
let running = false;
let visible = true;
let last = 0;
let sampleTime = 0;
let sampleFrames = 0;
const draw = () => {
ctx.clearRect(0, 0, width, height);
const cx = width / 2;
const cy = height / 2;
const R = Math.min(width, height) * radius;
if (R <= 0) return;
if (glow) {
const g = ctx.createRadialGradient(cx, cy, R * 0.1, cx, cy, R * 1.5);
g.addColorStop(0, `rgba(${front[0]}, ${front[1]}, ${front[2]}, 0.16)`);
g.addColorStop(1, `rgba(${front[0]}, ${front[1]}, ${front[2]}, 0)`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, width, height);
}
const cosY = Math.cos(angle + tiltY);
const sinY = Math.sin(angle + tiltY);
const cosX = Math.cos(tiltX);
const sinX = Math.sin(tiltX);
const influence = R * 0.55;
const push = R * 0.22 * pointer.strength;
const interact = interaction !== "none" && pointer.strength > 0.01;
for (let i = 0; i < count; i++) {
const px = points[i * 3];
const py = points[i * 3 + 1];
const pz = points[i * 3 + 2];
// Rotate around Y, then X.
const x1 = cosY * px + sinY * pz;
const z1 = -sinY * px + cosY * pz;
const y2 = cosX * py - sinX * z1;
const z2 = sinX * py + cosX * z1;
const persp = FOV / (FOV - z2);
let x = cx + x1 * R * persp;
let y = cy + y2 * R * persp;
const depth = (z2 + 1) / 2;
if (interact) {
const dx = x - pointer.x;
const dy = y - pointer.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
if (d < influence) {
const falloff = 1 - d / influence;
// Near particles respond more than those on the far side.
const f = falloff * falloff * push * (0.35 + 0.65 * depth);
const dir = interaction === "repel" ? 1 : -Math.min(1, d / (f + 0.0001));
x += (dx / d) * f * dir;
y += (dy / d) * f * dir;
}
}
sx[i] = x;
sy[i] = y;
sr[i] = size * (0.45 + 0.75 * depth) * persp;
bucket[i] = Math.min(BUCKETS - 1, Math.floor(depth * BUCKETS));
}
// One path per depth bucket keeps fill-style changes (and draw calls) to a handful.
for (let b = 0; b < BUCKETS; b++) {
ctx.beginPath();
for (let i = 0; i < count; i++) {
if (bucket[i] !== b) continue;
ctx.moveTo(sx[i] + sr[i], sy[i]);
ctx.arc(sx[i], sy[i], sr[i], 0, Math.PI * 2);
}
ctx.fillStyle = fills[b];
ctx.fill();
}
};
const resize = () => {
const rect = root.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (adaptive && width < 520 && count === target) setCount(Math.max(120, Math.round(target * 0.6)));
if (reduceMotion || !running) draw();
};
// Animation loop with offscreen / hidden-tab pausing and adaptive quality.
const loop = (now: number) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
last = now;
angle += dt * 0.32 * speed;
const ease = 1 - Math.pow(0.002, dt); // frame-rate independent smoothing
pointer.strength += ((pointer.inside ? 1 : 0) - pointer.strength) * ease;
const tx = tilt && pointer.inside ? pointer.ty * 0.5 - 0.25 : -0.25;
const ty = tilt && pointer.inside ? pointer.tx * 0.6 : 0;
tiltX += (tx - tiltX) * ease;
tiltY += (ty - tiltY) * ease;
draw();
if (adaptive) {
sampleTime += dt;
sampleFrames++;
if (sampleFrames >= 90) {
const avg = sampleTime / sampleFrames;
// Below ~40fps: shed a quarter of the particles (never below 30% of the target).
if (avg > 1 / 40 && count > target * 0.3) setCount(Math.round(count * 0.75));
sampleTime = 0;
sampleFrames = 0;
}
}
frame = requestAnimationFrame(loop);
};
const start = () => {
if (running || reduceMotion || !visible || document.hidden) return;
running = true;
last = 0;
frame = requestAnimationFrame(loop);
};
const stop = () => {
running = false;
cancelAnimationFrame(frame);
};
const ro = new ResizeObserver(resize);
ro.observe(root);
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) start();
else stop();
});
io.observe(root);
const onVisibility = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVisibility);
const onMove = (event: PointerEvent) => {
const rect = root.getBoundingClientRect();
pointer.x = event.clientX - rect.left;
pointer.y = event.clientY - rect.top;
pointer.tx = (pointer.x / rect.width) * 2 - 1;
pointer.ty = (pointer.y / rect.height) * 2 - 1;
pointer.inside = true;
};
const onLeave = () => {
pointer.inside = false;
};
if (!reduceMotion) {
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerdown", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointercancel", onLeave);
}
resize();
start();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerdown", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointercancel", onLeave);
};
}, [color, secondaryColor, density, size, speed, radius, interaction, tilt, adaptive, glow, reduceMotion]);
return (
<div
ref={rootRef}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={`relative h-full w-full touch-pan-y ${className}`}
{...props}
>
<canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
</div>
);
}