Canvas orbital particle field with elliptical trajectories, Z-depth scale and opacity modulation, pointer gravity shift, and automatic RAF cleanup.
Preview
Orbital Depth Particle Field
Customize
4 props34
0.65
0.55
Installation
Copies the component source and its CSS Module into your project.
npx motionfoundry add orbiting-particlesWrites components/ui/orbiting-particles.tsx and components/styles/orbiting-particles.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
example.tsx
1import { OrbitingParticles } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <OrbitingParticles />6 );7}Props
Accessibility
Marks the canvas aria-hidden='true', cleans up requestAnimationFrame and resize listeners on unmount, and renders a static orbital ring under prefers-reduced-motion.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
components/ui/orbiting-particles.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/orbiting-particles.module.css";5 6export interface OrbitingParticlesProps {7 /** Number of orbiting particles */8 particleCount?: number;9 /** Angular speed multiplier */10 orbitSpeed?: number;11 /** Ellipse eccentricity ratio */12 eccentricity?: number;13 /** Particle color */14 particleColor?: string;15 /** Optional center caption */16 label?: string;17 /** Optional className */18 className?: string;19}20 21export function OrbitingParticles({22 particleCount = 34,23 orbitSpeed = 0.65,24 eccentricity = 0.55,25 particleColor = "#e4e4e7",26 label = "Orbital Depth Particle Field",27 className = "",28}: OrbitingParticlesProps) {29 const canvasRef = React.useRef<HTMLCanvasElement>(null);30 const offsetRef = React.useRef({ x: 0, y: 0 });31 32 React.useEffect(() => {33 const canvas = canvasRef.current;34 if (!canvas) return;35 const ctx = canvas.getContext("2d");36 if (!ctx) return;37 38 const prefersReduced =39 typeof window !== "undefined" &&40 window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;41 42 let rafId = 0;43 const count = Math.min(Math.max(particleCount, 10), 80);44 45 const resize = () => {46 const parent = canvas.parentElement;47 if (!parent) return;48 canvas.width = parent.clientWidth;49 canvas.height = parent.clientHeight;50 };51 resize();52 53 const particles = Array.from({ length: count }, (_, i) => ({54 angle: (i / count) * Math.PI * 2,55 radius: 36 + ((i * 19) % 110),56 speed: (0.004 + (i % 5) * 0.0018) * orbitSpeed,57 size: 1.1 + (i % 3) * 0.65,58 }));59 60 const render = () => {61 ctx.clearRect(0, 0, canvas.width, canvas.height);62 const cx = canvas.width / 2 + offsetRef.current.x;63 const cy = canvas.height / 2 + offsetRef.current.y;64 65 for (const p of particles) {66 if (!prefersReduced) {67 p.angle += p.speed;68 }69 const x = cx + Math.cos(p.angle) * p.radius;70 const y = cy + Math.sin(p.angle) * p.radius * eccentricity;71 const depth = (Math.sin(p.angle) + 1) * 0.5; // 0 (back) to 1 (front)72 73 ctx.beginPath();74 ctx.arc(x, y, p.size * (0.65 + depth * 0.65), 0, Math.PI * 2);75 ctx.fillStyle = particleColor;76 ctx.globalAlpha = 0.2 + depth * 0.65;77 ctx.fill();78 }79 80 if (!prefersReduced) {81 rafId = window.requestAnimationFrame(render);82 }83 };84 85 render();86 window.addEventListener("resize", resize);87 return () => {88 window.removeEventListener("resize", resize);89 window.cancelAnimationFrame(rafId);90 };91 }, [particleCount, orbitSpeed, eccentricity, particleColor]);92 93 return (94 <div95 onPointerMove={(e) => {96 const rect = e.currentTarget.getBoundingClientRect();97 offsetRef.current = {98 x: ((e.clientX - rect.left) / rect.width - 0.5) * 28,99 y: ((e.clientY - rect.top) / rect.height - 0.5) * 18,100 };101 }}102 onPointerLeave={() => {103 offsetRef.current = { x: 0, y: 0 };104 }}105 className={`${styles.stage} ${className}`.trim()}106 >107 <canvas ref={canvasRef} aria-hidden="true" className={styles.canvas} />108 <span className={styles.caption}>{label}</span>109 </div>110 );111}