aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
428 lines (425 loc) • 15.7 kB
JavaScript
'use client';
import { jsx, jsxs } from 'react/jsx-runtime';
import { forwardRef, useState, useRef, useCallback, useEffect } from 'react';
import '../../primitives/GlassCore.js';
import '../../primitives/glass/GlassAdvanced.js';
import { OptimizedGlassCore } from '../../primitives/OptimizedGlassCore.js';
import '../../primitives/glass/OptimizedGlassAdvanced.js';
import '../../primitives/MotionNative.js';
import { MotionFramer } from '../../primitives/motion/MotionFramer.js';
import { cn } from '../../lib/utilsComprehensive.js';
import { useA11yId } from '../../utils/a11y.js';
import { useMotionPreferenceContext } from '../../contexts/MotionPreferenceContext.js';
const GlassParticleField = /*#__PURE__*/forwardRef(({
emitters,
forces = [],
maxParticles = 1000,
physics = true,
collisions = false,
trails = false,
interactive = true,
mouseForce,
bounds = {
width: 800,
height: 600,
depth: 100
},
performance = {
culling: true,
lodDistance: 200,
targetFPS: 60
},
effects = {
bloom: true,
blur: false,
glow: true,
sparkle: false
},
colorScheme = "cool",
speed = 1,
paused = false,
debug = false,
onParticleClick,
onEmitterTrigger,
onForceApply,
renderParticle,
respectMotionPreference = true,
className,
...props
}, ref) => {
const {
prefersReducedMotion
} = useMotionPreferenceContext();
const shouldAnimate = !prefersReducedMotion;
const particleFieldId = useA11yId("glass-particle-field");
const [particles, setParticles] = useState([]);
const [mousePosition, setMousePosition] = useState({
x: 0,
y: 0
});
const [isPlaying, setIsPlaying] = useState(!paused);
const [frameRate, setFrameRate] = useState(60);
const [particleCount, setParticleCount] = useState(0);
const containerRef = useRef(null);
const canvasRef = useRef(null);
const animationFrameRef = useRef();
const lastTimeRef = useRef(0);
const frameCountRef = useRef(0);
// Generate unique particle ID
const generateParticleId = useCallback(() => {
return `particle-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}, []);
// Create new particle
const createParticle = useCallback(emitter => {
const angle = emitter.angle.min + Math.random() * (emitter.angle.max - emitter.angle.min);
const velocity = emitter.velocity.min + Math.random() * (emitter.velocity.max - emitter.velocity.min);
const size = emitter.size.min + Math.random() * (emitter.size.max - emitter.size.min);
const life = emitter.life.min + Math.random() * (emitter.life.max - emitter.life.min);
const color = emitter.colors[Math.floor(Math.random() * emitter.colors.length)];
const vx = Math.cos(angle) * velocity;
const vy = Math.sin(angle) * velocity;
const vz = (Math.random() - 0.5) * velocity * 0.1;
return {
id: generateParticleId(),
x: emitter.x,
y: emitter.y,
z: emitter.z,
vx,
vy,
vz,
size,
life,
maxLife: life,
color,
opacity: 1,
type: "sphere",
mass: size * 0.1,
charge: 0,
trail: trails
};
}, [generateParticleId, trails]);
// Apply force to particle
const applyForce = useCallback((particle, force, deltaTime) => {
if (!force.enabled) return;
const dx = force.x - particle.x;
const dy = force.y - particle.y;
const dz = force.z - particle.z;
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (force.radius && distance > force.radius) return;
const strength = force.strength * deltaTime * speed;
switch (force.type) {
case "gravity":
particle.vy += strength;
break;
case "wind":
particle.vx += strength;
break;
case "attraction":
if (distance > 0) {
const forceX = dx / distance * strength / (distance + 1);
const forceY = dy / distance * strength / (distance + 1);
const forceZ = dz / distance * strength / (distance + 1);
particle.vx += forceX;
particle.vy += forceY;
particle.vz += forceZ;
}
break;
case "repulsion":
if (distance > 0) {
const forceX = -(dx / distance) * strength / (distance + 1);
const forceY = -(dy / distance) * strength / (distance + 1);
const forceZ = -(dz / distance) * strength / (distance + 1);
particle.vx += forceX;
particle.vy += forceY;
particle.vz += forceZ;
}
break;
case "vortex":
const angle = Math.atan2(dy, dx);
particle.vx += -Math.sin(angle) * strength;
particle.vy += Math.cos(angle) * strength;
break;
}
}, [speed]);
// Update particle physics
const updateParticle = useCallback((particle, deltaTime) => {
// Update life
particle.life -= deltaTime * speed;
if (particle.life <= 0) return null;
// Apply forces
forces.forEach(force => applyForce(particle, force, deltaTime));
// Apply mouse force
if (interactive && mouseForce) {
const dx = mousePosition.x - particle.x;
const dy = mousePosition.y - particle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < mouseForce.radius) {
const strength = mouseForce.strength * deltaTime * speed / (distance + 1);
const forceX = dx / distance * strength;
const forceY = dy / distance * strength;
if (mouseForce.type === "attraction") {
particle.vx += forceX;
particle.vy += forceY;
} else {
particle.vx -= forceX;
particle.vy -= forceY;
}
}
}
// Update position
particle.x += particle.vx * deltaTime * speed;
particle.y += particle.vy * deltaTime * speed;
particle.z += particle.vz * deltaTime * speed;
// Boundary conditions
if (particle.x < 0 || particle.x > bounds.width) particle.vx *= -0.8;
if (particle.y < 0 || particle.y > bounds.height) particle.vy *= -0.8;
if (particle.z < -bounds.depth || particle.z > bounds.depth) particle.vz *= -0.8;
// Clamp to bounds
particle.x = Math.max(0, Math.min(bounds.width, particle.x));
particle.y = Math.max(0, Math.min(bounds.height, particle.y));
particle.z = Math.max(-bounds.depth, Math.min(bounds.depth, particle.z));
// Update opacity based on life
particle.opacity = particle.life / particle.maxLife;
// Apply damping
particle.vx *= 0.99;
particle.vy *= 0.99;
particle.vz *= 0.99;
return particle;
}, [forces, applyForce, interactive, mouseForce, mousePosition, speed, bounds]);
// Emit particles
const emitParticles = useCallback((emitter, deltaTime, currentParticles) => {
if (!emitter.enabled || currentParticles.length >= maxParticles) return [];
const newParticles = [];
const particlesToEmit = Math.floor(emitter.rate * deltaTime);
for (let i = 0; i < particlesToEmit && currentParticles.length + newParticles.length < maxParticles; i++) {
newParticles.push(createParticle(emitter));
}
if (newParticles.length > 0) {
onEmitterTrigger?.(emitter);
}
return newParticles;
}, [maxParticles, createParticle, onEmitterTrigger]);
// Main animation loop
const animate = useCallback(currentTime => {
if (!isPlaying || paused) {
animationFrameRef.current = requestAnimationFrame(animate);
return;
}
const deltaTime = (currentTime - lastTimeRef.current) / 1000;
lastTimeRef.current = currentTime;
// Calculate frame rate
frameCountRef.current++;
if (frameCountRef.current % 60 === 0) {
setFrameRate(Math.round(1 / deltaTime));
}
setParticles(currentParticles => {
// Update existing particles
const updatedParticles = currentParticles.map(particle => updateParticle(particle, deltaTime)).filter(particle => particle !== null);
// Emit new particles
const newParticles = emitters.reduce((acc, emitter) => {
return [...acc, ...emitParticles(emitter, deltaTime, updatedParticles)];
}, []);
const allParticles = [...updatedParticles, ...newParticles];
// Apply forces globally
forces.forEach(force => {
if (force.enabled) {
onForceApply?.(force, allParticles);
}
});
setParticleCount(allParticles.length);
return allParticles;
});
animationFrameRef.current = requestAnimationFrame(animate);
}, [isPlaying, paused, updateParticle, emitters, emitParticles, forces, onForceApply]);
// Start animation
useEffect(() => {
if (shouldAnimate && !paused) {
lastTimeRef.current = performance.now();
animationFrameRef.current = requestAnimationFrame(animate);
}
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [shouldAnimate, paused, animate]);
// Handle mouse movement
const handleMouseMove = useCallback(event => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setMousePosition({
x: event.clientX - rect.left,
y: event.clientY - rect.top
});
}, []);
// Handle particle click
const handleParticleClick = useCallback(particle => {
onParticleClick?.(particle);
}, [onParticleClick]);
// Render particle on canvas
const renderParticleToCanvas = useCallback((ctx, particle, canvas) => {
ctx.save();
ctx.globalAlpha = particle.opacity;
// Apply 3D perspective
const perspective = 500;
const scale = perspective / (perspective + particle.z);
const x = particle.x * scale;
const y = particle.y * scale;
const size = particle.size * scale;
// Apply effects
if (effects.glow) {
ctx.shadowColor = particle.color;
ctx.shadowBlur = size * 2;
}
ctx.fillStyle = particle.color;
switch (particle.type) {
case "sphere":
ctx.beginPath();
ctx.arc(x, y, size / 2, 0, Math.PI * 2);
ctx.fill();
break;
case "square":
ctx.fillRect(x - size / 2, y - size / 2, size, size);
break;
case "triangle":
ctx.beginPath();
ctx.moveTo(x, y - size / 2);
ctx.lineTo(x - size / 2, y + size / 2);
ctx.lineTo(x + size / 2, y + size / 2);
ctx.closePath();
ctx.fill();
break;
case "star":
const spikes = 5;
const outerRadius = size / 2;
const innerRadius = outerRadius * 0.4;
ctx.beginPath();
for (let i = 0; i < spikes * 2; i++) {
const radius = i % 2 === 0 ? outerRadius : innerRadius;
const angle = i * Math.PI / spikes;
const px = x + Math.cos(angle) * radius;
const py = y + Math.sin(angle) * radius;
if (i === 0) ctx.moveTo(px, py);else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fill();
break;
}
// Render trail
if (particle.trail && particle.vx !== 0 && particle.vy !== 0) {
ctx.strokeStyle = particle.color;
ctx.lineWidth = size * 0.1;
ctx.globalAlpha = particle.opacity * 0.3;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - particle.vx * 5, y - particle.vy * 5);
ctx.stroke();
}
ctx.restore();
}, [effects]);
// Render to canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Set canvas size
canvas.width = bounds.width;
canvas.height = bounds.height;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply effects
if (effects.blur) {
ctx.filter = "blur(1px)";
}
// Render particles
particles.forEach(particle => {
if (renderParticle) ; else {
renderParticleToCanvas(ctx, particle, canvas);
}
});
// Render debug info
if (debug) {
ctx.fillStyle = "rgba(var(--glass-color-white) / var(--glass-opacity-80))";
ctx.font = "12px monospace";
ctx.fillText(`Particles: ${particles.length}`, 10, 20);
ctx.fillText(`FPS: ${frameRate}`, 10, 40);
ctx.fillText(`Emitters: ${emitters.filter(e => e.enabled).length}`, 10, 60);
ctx.fillText(`Forces: ${forces.filter(f => f.enabled).length}`, 10, 80);
}
}, [particles, bounds, effects, renderParticle, renderParticleToCanvas, debug, frameRate, emitters, forces]);
return jsx(OptimizedGlassCore, {
ref: ref,
id: particleFieldId,
elevation: "level1",
intensity: "medium",
depth: 1,
tint: "neutral",
border: "subtle",
className: cn("glass-particle-field relative glass-radius-lg glass-backdrop-blur-md border border-border/20 overflow-hidden", className),
style: {
width: bounds.width,
height: bounds.height
},
onMouseMove: handleMouseMove,
...props,
children: jsxs(MotionFramer, {
preset: shouldAnimate && respectMotionPreference ? "fadeIn" : "none",
className: 'relative glass-w-full glass-h-full',
children: [jsx("canvas", {
ref: canvasRef,
className: 'absolute inset-0 glass-w-full glass-h-full',
style: {
filter: effects.bloom ? "drop-shadow(0 0 10px var(--glass-color-primary, 0.3))" : undefined
}
}), renderParticle && jsx("div", {
className: 'absolute inset-0 pointer-events-none',
children: particles.map(particle => jsx("div", {
className: 'absolute pointer-events-auto cursor-pointer',
style: {
left: particle.x - particle.size / 2,
top: particle.y - particle.size / 2,
transform: `translateZ(${particle.z}px) scale(${500 / (500 + particle.z)})`,
opacity: particle.opacity
},
onClick: () => handleParticleClick(particle),
children: renderParticle(particle)
}, particle.id))
}), jsx("div", {
className: 'absolute bottom-4 left-1/2 transform -translate-x-1/2',
children: jsxs(OptimizedGlassCore, {
elevation: "level3",
intensity: "strong",
depth: 2,
tint: "neutral",
border: "subtle",
className: "glass-flex glass-items-center glass-gap-2 glass-px-4 glass-py-2 glass-radius-lg glass-glass-backdrop-blur-md glass-border glass-border-glass-border/20 glass-contrast-guard",
children: [jsx("button", {
onClick: () => setIsPlaying(!isPlaying),
className: 'glass-p-2 glass-radius-md hover:glass-surface-overlay transition-all',
title: isPlaying ? "Pause" : "Play",
children: isPlaying ? "⏸" : "▶"
}), jsxs("div", {
className: "glass-text-xs glass-text-secondary",
children: ["Particles: ", particleCount]
}), debug && jsxs("div", {
className: "glass-text-xs glass-text-secondary",
children: ["FPS: ", frameRate]
})]
})
}), interactive && mouseForce && jsx("div", {
className: 'absolute glass-radius-full glass-border glass-border-primary/30 pointer-events-none',
style: {
left: mousePosition.x - mouseForce.radius,
top: mousePosition.y - mouseForce.radius,
width: mouseForce.radius * 2,
height: mouseForce.radius * 2,
backgroundColor: mouseForce.type === "attraction" ? "var(--glass-color-primary, 0.1)" : "var(--glass-color-danger, 0.1)"
}
})]
})
});
});
GlassParticleField.displayName = "GlassParticleField";
export { GlassParticleField, GlassParticleField as default };
//# sourceMappingURL=GlassParticleField.js.map