From a502cdfe88ca9fa528c82255129c58361c830a1a Mon Sep 17 00:00:00 2001 From: Miles <364475182@qq.com> Date: Mon, 17 Aug 2026 11:17:47 +0800 Subject: [PATCH] feat(floating-3d-particles): add canvas-based 3d particle background Add a pseudo-3D floating particle background component, rebuilt from the classic GitMind hero effect as a reusable, configurable UI component. - Perspective-projected particle field with continuous rotation, vertical floating and depth-based scaling - 22 props: particle counts, color, size/opacity ranges, rotation/float speed, perspective (fov, distance, depth), mouse interaction, DPI scaling, pause-when-hidden and reduced-motion support - Mouse rotation listens on window so the canvas stays pointer-events-none and never blocks clicks; disable with mouseInteraction={false} - Responsive particle count based on the viewport breakpoint - Default demo and hero-content demo, docs page and registry artifacts --- apps/www/config/docs.ts | 6 + .../docs/components/floating-3d-particles.mdx | 94 +++ apps/www/public/llms-full.txt | 652 ++++++++++++++++++ apps/www/public/llms.txt | 3 + .../r/floating-3d-particles-demo-2.json | 17 + .../public/r/floating-3d-particles-demo.json | 17 + apps/www/public/r/floating-3d-particles.json | 14 + apps/www/public/r/registry.json | 42 ++ apps/www/public/registry.json | 42 ++ apps/www/registry.json | 42 ++ apps/www/registry/__index__.tsx | 51 ++ .../example/floating-3d-particles-demo-2.tsx | 30 + .../example/floating-3d-particles-demo.tsx | 11 + .../magicui/floating-3d-particles.tsx | 591 ++++++++++++++++ apps/www/registry/registry-examples.ts | 28 + apps/www/registry/registry-ui.ts | 13 + 16 files changed, 1653 insertions(+) create mode 100644 apps/www/content/docs/components/floating-3d-particles.mdx create mode 100644 apps/www/public/r/floating-3d-particles-demo-2.json create mode 100644 apps/www/public/r/floating-3d-particles-demo.json create mode 100644 apps/www/public/r/floating-3d-particles.json create mode 100644 apps/www/registry/example/floating-3d-particles-demo-2.tsx create mode 100644 apps/www/registry/example/floating-3d-particles-demo.tsx create mode 100644 apps/www/registry/magicui/floating-3d-particles.tsx diff --git a/apps/www/config/docs.ts b/apps/www/config/docs.ts index ddb7c84f8..74405e34a 100644 --- a/apps/www/config/docs.ts +++ b/apps/www/config/docs.ts @@ -562,6 +562,12 @@ export const docsConfig: DocsConfig = { items: [], label: "New", }, + { + title: "Floating 3D Particles", + href: `/docs/components/floating-3d-particles`, + items: [], + label: "New", + }, ], }, { diff --git a/apps/www/content/docs/components/floating-3d-particles.mdx b/apps/www/content/docs/components/floating-3d-particles.mdx new file mode 100644 index 000000000..4770344c8 --- /dev/null +++ b/apps/www/content/docs/components/floating-3d-particles.mdx @@ -0,0 +1,94 @@ +--- +title: Floating 3D Particles +date: 2026-08-16 +description: A canvas-based pseudo-3D particle field with perspective projection, continuous rotation and mouse interaction. +author: Miles +published: true +--- + + + +## Installation + + + + + CLI + Manual + + + +```bash +npx shadcn@latest add @magicui/floating-3d-particles +``` + + + + + + + +Copy and paste the following code into your project. + + + +Update the import paths to match your project setup. + + + + + + + +## Examples + +### With hero content + + + +## Usage + +`Floating3DParticles` renders a full-size `canvas` (absolute, inset 0). Put it inside a `relative` container and layer content above with `z-10` when needed. The canvas never intercepts pointer events, so it can sit directly behind your content while the particle field rotates with mouse movement. Set `mouseInteraction={false}` to disable the mouse rotation and keep only the base `rotationSpeed`. + +```tsx showLineNumbers +import { Floating3DParticles } from "@/components/ui/floating-3d-particles" +``` + +```tsx showLineNumbers +
+ +
+``` + +## Props + +`Floating3DParticlesProps` extends React's `CanvasHTMLAttributes` (minus `width` and `height`). Besides standard canvas attributes, these props are supported: + +### Floating3DParticles + +| Prop | Type | Default | Description | +| ---------------------- | --------- | --------------- | --------------------------------------------------------------- | +| `particleCount` | `number` | `400` | Number of particles on desktop. | +| `mobileParticleCount` | `number` | `80` | Number of particles below the mobile breakpoint. | +| `mobileBreakpoint` | `number` | `768` | Viewport width below which the mobile particle count is used. | +| `color` | `string` | `"#8B5CF6"` | Particle color, accepts 3 or 6 digit hex. | +| `minParticleSize` | `number` | `3` | Minimum particle radius in px. | +| `maxParticleSize` | `number` | `8` | Maximum particle radius in px. | +| `minOpacity` | `number` | `0.1` | Minimum particle opacity. | +| `maxOpacity` | `number` | `0.5` | Maximum particle opacity. | +| `rotationSpeed` | `number` | `0.002` | Base angular velocity added to every particle. | +| `floatSpeed` | `number` | `0.8` | Vertical floating speed in px per frame. | +| `radiusScale` | `number` | `1.2` | Max particle orbit radius as a multiple of the container width. | +| `fov` | `number` | `400` | Perspective field of view. | +| `perspectiveDistance` | `number` | `500` | Perspective distance from the viewer. | +| `depthRange` | `number` | `200` | Amplitude of the depth oscillation used by the projection. | +| `mouseInteraction` | `boolean` | `true` | Whether moving the mouse rotates the particle field. | +| `mouseSensitivity` | `number` | `0.0001` | Mouse rotation sensitivity. | +| `smoothMouse` | `boolean` | `true` | Whether mouse rotation is smoothed with linear interpolation. | +| `mouseSmoothing` | `number` | `0.08` | Mouse smoothing factor, between 0 and 1. | +| `highDpi` | `boolean` | `true` | Whether to scale the canvas by devicePixelRatio. | +| `maxDpr` | `number` | `2` | Cap applied to devicePixelRatio. | +| `pauseWhenHidden` | `boolean` | `true` | Pause rendering while off-screen or the tab is hidden. | +| `respectReducedMotion` | `boolean` | `true` | Render a static frame instead of animating for reduced motion. | +| `background` | `string` | `"transparent"` | Background color of the canvas. | +| `zIndex` | `number` | `0` | CSS z-index of the canvas. | diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index 8fa0f92c9..c4d8f24b7 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -8400,6 +8400,658 @@ export default function FlickeringGridRoundedDemo() { +===== COMPONENT: floating-3d-particles ===== +Title: Floating 3D Particles +Description: A canvas-based pseudo-3D particle field with perspective projection, rotation and mouse interaction. + +--- file: magicui/floating-3d-particles.tsx --- +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface Floating3DParticlesProps extends Omit< + React.CanvasHTMLAttributes, + "width" | "height" +> { + /** + * Number of particles on desktop. + * @default 400 + */ + particleCount?: number + /** + * Number of particles below the mobile breakpoint. + * @default 80 + */ + mobileParticleCount?: number + /** + * Container width below which the mobile particle count is used. + * @default 768 + */ + mobileBreakpoint?: number + /** + * Particle color, accepts 3 or 6 digit hex. + * @default "#8B5CF6" + */ + color?: string + /** + * Minimum particle radius in px. + * @default 3 + */ + minParticleSize?: number + /** + * Maximum particle radius in px. + * @default 8 + */ + maxParticleSize?: number + /** + * Minimum particle opacity. + * @default 0.1 + */ + minOpacity?: number + /** + * Maximum particle opacity. + * @default 0.5 + */ + maxOpacity?: number + /** + * Base angular velocity added to every particle. + * @default 0.002 + */ + rotationSpeed?: number + /** + * Vertical floating speed in px per frame. + * @default 0.8 + */ + floatSpeed?: number + /** + * Max particle orbit radius as a multiple of the container width. + * @default 1.2 + */ + radiusScale?: number + /** + * Perspective field of view. + * @default 400 + */ + fov?: number + /** + * Perspective distance from the viewer. + * @default 500 + */ + perspectiveDistance?: number + /** + * Amplitude of the depth oscillation used by the projection. + * @default 200 + */ + depthRange?: number + /** + * Whether moving the mouse rotates the particle field. Listeners are + * attached to window while the canvas stays pointer-events-none, so the + * interaction never blocks clicks on the content above. Set to false to + * disable the mouse interaction entirely. + * @default true + */ + mouseInteraction?: boolean + /** + * Mouse rotation sensitivity. + * @default 0.0001 + */ + mouseSensitivity?: number + /** + * Whether mouse rotation is smoothed with linear interpolation. + * @default true + */ + smoothMouse?: boolean + /** + * Mouse smoothing factor, between 0 and 1. + * @default 0.08 + */ + mouseSmoothing?: number + /** + * Whether to scale the canvas by devicePixelRatio for sharper rendering. + * @default true + */ + highDpi?: boolean + /** + * Cap applied to devicePixelRatio. + * @default 2 + */ + maxDpr?: number + /** + * Whether to pause rendering while the canvas is off-screen or the tab is + * hidden. + * @default true + */ + pauseWhenHidden?: boolean + /** + * Whether to respect prefers-reduced-motion. When enabled and the user + * prefers reduced motion, a single static frame is rendered instead of an + * animation. + * @default true + */ + respectReducedMotion?: boolean + /** + * Background color of the canvas. + * @default "transparent" + */ + background?: string + /** + * CSS z-index of the canvas. + * @default 0 + */ + zIndex?: number +} + +interface Particle { + angle: number + radius: number + y: number + size: number + speed: number + opacity: number + screenX: number + screenY: number + projectedScale: number +} + +const DEFAULT_COLOR = "#8B5CF6" + +function hexToRgba(hex: string, alpha: number) { + const clean = hex.replace("#", "").trim() + const value = + clean.length === 3 + ? clean + .split("") + .map((char) => char + char) + .join("") + : clean + + if (!/^[0-9a-f]{6}$/i.test(value)) { + return `rgba(139, 92, 246, ${alpha})` + } + + const parsed = Number.parseInt(value, 16) + const r = Math.floor(parsed / 0x10000) % 0x100 + const g = Math.floor(parsed / 0x100) % 0x100 + const b = parsed % 0x100 + + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +function createParticle( + width: number, + height: number, + config: { + radiusScale: number + minParticleSize: number + maxParticleSize: number + minOpacity: number + maxOpacity: number + } +): Particle { + return { + angle: Math.random() * Math.PI * 2, + radius: Math.random() * width * config.radiusScale, + y: (Math.random() - 0.5) * height * 2, + size: + config.minParticleSize + + Math.random() * (config.maxParticleSize - config.minParticleSize), + speed: 0.0005 + Math.random() * 0.0015, + opacity: + config.minOpacity + + Math.random() * (config.maxOpacity - config.minOpacity), + screenX: 0, + screenY: 0, + projectedScale: 1, + } +} + +/** + * Resolves the particle count based on the viewport width. Matches the + * original implementation: the breakpoint is based on the viewport width, not + * the container width. + */ +function getParticleCount( + viewportWidth: number, + mobileBreakpoint: number, + particleCount: number, + mobileParticleCount: number +) { + return viewportWidth < mobileBreakpoint ? mobileParticleCount : particleCount +} + +/** + * Resolves the devicePixelRatio applied to the canvas. + */ +function getDpr(devicePixelRatio: number, highDpi: boolean, maxDpr: number) { + return highDpi ? Math.min(devicePixelRatio || 1, maxDpr) : 1 +} + +/** + * Computes the mouse-driven rotation offset around the canvas center. + */ +function getTargetRotation( + clientX: number, + centerX: number, + sensitivity: number +) { + return (clientX - centerX) * sensitivity +} + +/** + * Interpolates the current rotation towards the target rotation. + */ +function smoothRotation(current: number, target: number, smoothing: number) { + return current + (target - current) * smoothing +} + +/** + * Projects a particle without any motion (used for the reduced-motion static + * frame). + */ +function projectParticle( + particle: Particle, + options: { + width: number + height: number + fov: number + perspectiveDistance: number + } +) { + const scale = options.fov / (options.fov + options.perspectiveDistance) + + particle.screenX = + options.width / 2 + Math.cos(particle.angle) * particle.radius * scale + particle.screenY = options.height / 2 + particle.y * scale + particle.projectedScale = scale +} + +/** + * Advances a particle by one frame: applies rotation, floating and the + * perspective projection, and respawns the particle at the bottom when it + * leaves the top edge. + */ +function updateParticle( + particle: Particle, + options: { + width: number + height: number + rotation: number + floatSpeed: number + radiusScale: number + fov: number + perspectiveDistance: number + depthRange: number + } +) { + particle.angle += particle.speed + options.rotation + particle.y -= options.floatSpeed + + if (particle.y < -options.height) { + particle.y = options.height + particle.radius = Math.random() * options.width * options.radiusScale + } + + const scale = + options.fov / + (options.fov + + options.perspectiveDistance + + Math.sin(particle.angle) * options.depthRange) + + particle.screenX = + options.width / 2 + Math.cos(particle.angle) * particle.radius * scale + particle.screenY = options.height / 2 + particle.y * scale + particle.projectedScale = scale +} + +export function Floating3DParticles({ + particleCount = 400, + mobileParticleCount = 80, + mobileBreakpoint = 768, + color = DEFAULT_COLOR, + minParticleSize = 3, + maxParticleSize = 8, + minOpacity = 0.1, + maxOpacity = 0.5, + rotationSpeed = 0.002, + floatSpeed = 0.8, + radiusScale = 1.2, + fov = 400, + perspectiveDistance = 500, + depthRange = 200, + mouseInteraction = true, + mouseSensitivity = 0.0001, + smoothMouse = true, + mouseSmoothing = 0.08, + highDpi = true, + maxDpr = 2, + pauseWhenHidden = true, + respectReducedMotion = true, + background = "transparent", + zIndex = 0, + className, + style, + ...canvasProps +}: Floating3DParticlesProps) { + const canvasRef = React.useRef(null) + const animationFrameRef = React.useRef(null) + const resizeObserverRef = React.useRef(null) + const visibilityObserverRef = React.useRef(null) + const mountedRef = React.useRef(false) + const pausedRef = React.useRef(false) + const reducedMotionRef = React.useRef(false) + const targetRotationRef = React.useRef(0) + const currentRotationRef = React.useRef(0) + + React.useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext("2d", { alpha: true }) + if (!ctx) return + + mountedRef.current = true + + let width = 0 + let height = 0 + let dpr = 1 + let particles: Particle[] = [] + let staticFrameDrawn = false + + const reducedMotionQuery = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ) + + const updateReducedMotion = () => { + reducedMotionRef.current = + respectReducedMotion && reducedMotionQuery.matches + } + + updateReducedMotion() + + const resize = () => { + const rect = canvas.getBoundingClientRect() + width = Math.max(1, Math.round(rect.width)) + height = Math.max(1, Math.round(rect.height)) + + dpr = getDpr(window.devicePixelRatio || 1, highDpi, maxDpr) + + canvas.width = Math.round(width * dpr) + canvas.height = Math.round(height * dpr) + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + const count = getParticleCount( + window.innerWidth, + mobileBreakpoint, + particleCount, + mobileParticleCount + ) + + particles = Array.from({ length: Math.max(0, count) }, () => + createParticle(width, height, { + radiusScale, + minParticleSize, + maxParticleSize, + minOpacity, + maxOpacity, + }) + ) + + staticFrameDrawn = false + } + + const drawParticle = (particle: Particle) => { + const alpha = particle.opacity * Math.min(1, particle.projectedScale) + const size = particle.size * particle.projectedScale + + if (size <= 0) return + + ctx.beginPath() + ctx.fillStyle = hexToRgba(color, alpha) + ctx.arc(particle.screenX, particle.screenY, size, 0, Math.PI * 2) + ctx.fill() + } + + const clear = () => { + ctx.clearRect(0, 0, width, height) + } + + const renderStaticFrame = () => { + clear() + for (const particle of particles) { + projectParticle(particle, { width, height, fov, perspectiveDistance }) + drawParticle(particle) + } + } + + const render = () => { + if (!mountedRef.current) return + + if (pausedRef.current) { + animationFrameRef.current = requestAnimationFrame(render) + return + } + + // Reduced motion: draw a single static frame and stop. + if (reducedMotionRef.current) { + if (!staticFrameDrawn) { + staticFrameDrawn = true + renderStaticFrame() + } + return + } + + clear() + + currentRotationRef.current = smoothMouse + ? smoothRotation( + currentRotationRef.current, + targetRotationRef.current, + mouseSmoothing + ) + : targetRotationRef.current + + const rotation = rotationSpeed + currentRotationRef.current + + for (const particle of particles) { + updateParticle(particle, { + width, + height, + rotation, + floatSpeed, + radiusScale, + fov, + perspectiveDistance, + depthRange, + }) + } + + // Painter's algorithm: draw distant particles first. + particles.sort((a, b) => a.projectedScale - b.projectedScale) + + for (const particle of particles) { + drawParticle(particle) + } + + animationFrameRef.current = requestAnimationFrame(render) + } + + const handleMouseMove = (event: MouseEvent) => { + if (!mouseInteraction) return + + const rect = canvas.getBoundingClientRect() + const centerX = rect.left + rect.width / 2 + targetRotationRef.current = getTargetRotation( + event.clientX, + centerX, + mouseSensitivity + ) + } + + const resetMouseRotation = () => { + targetRotationRef.current = 0 + } + + const handleVisibilityChange = () => { + if (pauseWhenHidden) { + pausedRef.current = document.hidden + } + } + + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(resize) + resizeObserverRef.current = observer + observer.observe(canvas) + } else { + window.addEventListener("resize", resize) + } + + if (pauseWhenHidden && typeof IntersectionObserver !== "undefined") { + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0] + if (!entry) return + pausedRef.current = document.hidden || !entry.isIntersecting + }, + { threshold: 0 } + ) + visibilityObserverRef.current = observer + observer.observe(canvas) + } + + // Mouse listeners are attached to window so the canvas can keep + // `pointer-events-none` and never block clicks on the content above it. + if (mouseInteraction) { + window.addEventListener("mousemove", handleMouseMove) + window.addEventListener("blur", resetMouseRotation) + } + document.addEventListener("visibilitychange", handleVisibilityChange) + reducedMotionQuery.addEventListener("change", updateReducedMotion) + + resize() + animationFrameRef.current = requestAnimationFrame(render) + + return () => { + mountedRef.current = false + + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = null + } + + resizeObserverRef.current?.disconnect() + resizeObserverRef.current = null + visibilityObserverRef.current?.disconnect() + visibilityObserverRef.current = null + + if (typeof ResizeObserver === "undefined") { + window.removeEventListener("resize", resize) + } + + if (mouseInteraction) { + window.removeEventListener("mousemove", handleMouseMove) + window.removeEventListener("blur", resetMouseRotation) + } + document.removeEventListener("visibilitychange", handleVisibilityChange) + reducedMotionQuery.removeEventListener("change", updateReducedMotion) + } + }, [ + particleCount, + mobileParticleCount, + mobileBreakpoint, + color, + minParticleSize, + maxParticleSize, + minOpacity, + maxOpacity, + rotationSpeed, + floatSpeed, + radiusScale, + fov, + perspectiveDistance, + depthRange, + mouseInteraction, + mouseSensitivity, + smoothMouse, + mouseSmoothing, + highDpi, + maxDpr, + pauseWhenHidden, + respectReducedMotion, + ]) + + return ( +