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 (
+
+ )
+}
+
+export default Floating3DParticles
+
+
+===== EXAMPLE: floating-3d-particles-demo =====
+Title: Floating 3D Particles Demo
+
+--- file: example/floating-3d-particles-demo.tsx ---
+"use client"
+
+import { Floating3DParticles } from "@/registry/magicui/floating-3d-particles"
+
+export default function Component() {
+ return (
+
+
+
+ )
+}
+
+
+===== EXAMPLE: floating-3d-particles-demo-2 =====
+Title: Floating 3D Particles Demo 2
+
+--- file: example/floating-3d-particles-demo-2.tsx ---
+"use client"
+
+import { ArrowRight } from "lucide-react"
+
+import { Floating3DParticles } from "@/registry/magicui/floating-3d-particles"
+
+export default function Component() {
+ return (
+
+
+
+
+
+ Build something magical
+
+
+ A pseudo-3D particle background that stays behind your content and
+ follows the mouse.
+
+
+ Get Started
+
+
+
+
+ )
+}
+
+
+
===== COMPONENT: glare-hover =====
Title: Glare Hover
Description: A diagonal glare on hover using a ::before gradient and CSS variables (angle, size, duration, color).
diff --git a/apps/www/public/llms.txt b/apps/www/public/llms.txt
index a375dac2d..74a763bff 100644
--- a/apps/www/public/llms.txt
+++ b/apps/www/public/llms.txt
@@ -31,6 +31,7 @@ This file provides LLM-friendly entry points to documentation and examples.
- [Dotted Map](https://magicui.design/docs/components/dotted-map): A component with a dotted map.
- [File Tree](https://magicui.design/docs/components/file-tree): A component used to showcase the folder and file structure of a directory.
- [Flickering Grid](https://magicui.design/docs/components/flickering-grid): A flickering grid background made with SVGs, fully customizable using Tailwind CSS.
+- [Floating 3D Particles](https://magicui.design/docs/components/floating-3d-particles): A canvas-based pseudo-3D particle field with perspective projection, rotation and mouse interaction.
- [Glare Hover](https://magicui.design/docs/components/glare-hover): A diagonal glare on hover using a ::before gradient and CSS variables (angle, size, duration, color).
- [Globe](https://magicui.design/docs/components/globe): An autorotating, interactive, and highly performant globe made using WebGL.
- [Glyph Matrix](https://magicui.design/docs/components/glyph-matrix): An animated grid of subtly shifting glyphs with fade effect and theme support.
@@ -254,6 +255,8 @@ This file provides LLM-friendly entry points to documentation and examples.
- [kinetic-text-demo](https://github.com/magicuidesign/magicui/blob/main/example/kinetic-text-demo.tsx): Example usage
- [Text 3D Flip Demo](https://github.com/magicuidesign/magicui/blob/main/example/text-3d-flip-demo.tsx): Example usage
- [Text 3D Flip Demo 2](https://github.com/magicuidesign/magicui/blob/main/example/text-3d-flip-demo-2.tsx): Example usage
+- [Floating 3D Particles Demo](https://github.com/magicuidesign/magicui/blob/main/example/floating-3d-particles-demo.tsx): Example usage
+- [Floating 3D Particles Demo 2](https://github.com/magicuidesign/magicui/blob/main/example/floating-3d-particles-demo-2.tsx): Example usage
## Optional
diff --git a/apps/www/public/r/floating-3d-particles-demo-2.json b/apps/www/public/r/floating-3d-particles-demo-2.json
new file mode 100644
index 000000000..ffd053f6b
--- /dev/null
+++ b/apps/www/public/r/floating-3d-particles-demo-2.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "floating-3d-particles-demo-2",
+ "type": "registry:example",
+ "title": "Floating 3D Particles Demo 2",
+ "description": "Example showing the particle field layered behind hero content.",
+ "registryDependencies": [
+ "@magicui/floating-3d-particles"
+ ],
+ "files": [
+ {
+ "path": "registry/example/floating-3d-particles-demo-2.tsx",
+ "content": "\"use client\"\n\nimport { ArrowRight } from \"lucide-react\"\n\nimport { Floating3DParticles } from \"@/registry/magicui/floating-3d-particles\"\n\nexport default function Component() {\n return (\n \n
\n\n
\n
\n Build something magical\n
\n
\n A pseudo-3D particle background that stays behind your content and\n follows the mouse.\n
\n
\n Get Started\n \n \n
\n
\n )\n}\n",
+ "type": "registry:example"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/www/public/r/floating-3d-particles-demo.json b/apps/www/public/r/floating-3d-particles-demo.json
new file mode 100644
index 000000000..bf36dbbb0
--- /dev/null
+++ b/apps/www/public/r/floating-3d-particles-demo.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "floating-3d-particles-demo",
+ "type": "registry:example",
+ "title": "Floating 3D Particles Demo",
+ "description": "Example showing a floating 3D particle field behind hero content.",
+ "registryDependencies": [
+ "@magicui/floating-3d-particles"
+ ],
+ "files": [
+ {
+ "path": "registry/example/floating-3d-particles-demo.tsx",
+ "content": "\"use client\"\n\nimport { Floating3DParticles } from \"@/registry/magicui/floating-3d-particles\"\n\nexport default function Component() {\n return (\n \n \n
\n )\n}\n",
+ "type": "registry:example"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/www/public/r/floating-3d-particles.json b/apps/www/public/r/floating-3d-particles.json
new file mode 100644
index 000000000..3464c3711
--- /dev/null
+++ b/apps/www/public/r/floating-3d-particles.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "floating-3d-particles",
+ "type": "registry:ui",
+ "title": "Floating 3D Particles",
+ "description": "A canvas-based pseudo-3D particle field with perspective projection, rotation and mouse interaction.",
+ "files": [
+ {
+ "path": "registry/magicui/floating-3d-particles.tsx",
+ "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface Floating3DParticlesProps extends Omit<\n React.CanvasHTMLAttributes,\n \"width\" | \"height\"\n> {\n /**\n * Number of particles on desktop.\n * @default 400\n */\n particleCount?: number\n /**\n * Number of particles below the mobile breakpoint.\n * @default 80\n */\n mobileParticleCount?: number\n /**\n * Container width below which the mobile particle count is used.\n * @default 768\n */\n mobileBreakpoint?: number\n /**\n * Particle color, accepts 3 or 6 digit hex.\n * @default \"#8B5CF6\"\n */\n color?: string\n /**\n * Minimum particle radius in px.\n * @default 3\n */\n minParticleSize?: number\n /**\n * Maximum particle radius in px.\n * @default 8\n */\n maxParticleSize?: number\n /**\n * Minimum particle opacity.\n * @default 0.1\n */\n minOpacity?: number\n /**\n * Maximum particle opacity.\n * @default 0.5\n */\n maxOpacity?: number\n /**\n * Base angular velocity added to every particle.\n * @default 0.002\n */\n rotationSpeed?: number\n /**\n * Vertical floating speed in px per frame.\n * @default 0.8\n */\n floatSpeed?: number\n /**\n * Max particle orbit radius as a multiple of the container width.\n * @default 1.2\n */\n radiusScale?: number\n /**\n * Perspective field of view.\n * @default 400\n */\n fov?: number\n /**\n * Perspective distance from the viewer.\n * @default 500\n */\n perspectiveDistance?: number\n /**\n * Amplitude of the depth oscillation used by the projection.\n * @default 200\n */\n depthRange?: number\n /**\n * Whether moving the mouse rotates the particle field. Listeners are\n * attached to window while the canvas stays pointer-events-none, so the\n * interaction never blocks clicks on the content above. Set to false to\n * disable the mouse interaction entirely.\n * @default true\n */\n mouseInteraction?: boolean\n /**\n * Mouse rotation sensitivity.\n * @default 0.0001\n */\n mouseSensitivity?: number\n /**\n * Whether mouse rotation is smoothed with linear interpolation.\n * @default true\n */\n smoothMouse?: boolean\n /**\n * Mouse smoothing factor, between 0 and 1.\n * @default 0.08\n */\n mouseSmoothing?: number\n /**\n * Whether to scale the canvas by devicePixelRatio for sharper rendering.\n * @default true\n */\n highDpi?: boolean\n /**\n * Cap applied to devicePixelRatio.\n * @default 2\n */\n maxDpr?: number\n /**\n * Whether to pause rendering while the canvas is off-screen or the tab is\n * hidden.\n * @default true\n */\n pauseWhenHidden?: boolean\n /**\n * Whether to respect prefers-reduced-motion. When enabled and the user\n * prefers reduced motion, a single static frame is rendered instead of an\n * animation.\n * @default true\n */\n respectReducedMotion?: boolean\n /**\n * Background color of the canvas.\n * @default \"transparent\"\n */\n background?: string\n /**\n * CSS z-index of the canvas.\n * @default 0\n */\n zIndex?: number\n}\n\ninterface Particle {\n angle: number\n radius: number\n y: number\n size: number\n speed: number\n opacity: number\n screenX: number\n screenY: number\n projectedScale: number\n}\n\nconst DEFAULT_COLOR = \"#8B5CF6\"\n\nfunction hexToRgba(hex: string, alpha: number) {\n const clean = hex.replace(\"#\", \"\").trim()\n const value =\n clean.length === 3\n ? clean\n .split(\"\")\n .map((char) => char + char)\n .join(\"\")\n : clean\n\n if (!/^[0-9a-f]{6}$/i.test(value)) {\n return `rgba(139, 92, 246, ${alpha})`\n }\n\n const parsed = Number.parseInt(value, 16)\n const r = Math.floor(parsed / 0x10000) % 0x100\n const g = Math.floor(parsed / 0x100) % 0x100\n const b = parsed % 0x100\n\n return `rgba(${r}, ${g}, ${b}, ${alpha})`\n}\n\nfunction createParticle(\n width: number,\n height: number,\n config: {\n radiusScale: number\n minParticleSize: number\n maxParticleSize: number\n minOpacity: number\n maxOpacity: number\n }\n): Particle {\n return {\n angle: Math.random() * Math.PI * 2,\n radius: Math.random() * width * config.radiusScale,\n y: (Math.random() - 0.5) * height * 2,\n size:\n config.minParticleSize +\n Math.random() * (config.maxParticleSize - config.minParticleSize),\n speed: 0.0005 + Math.random() * 0.0015,\n opacity:\n config.minOpacity +\n Math.random() * (config.maxOpacity - config.minOpacity),\n screenX: 0,\n screenY: 0,\n projectedScale: 1,\n }\n}\n\n/**\n * Resolves the particle count based on the viewport width. Matches the\n * original implementation: the breakpoint is based on the viewport width, not\n * the container width.\n */\nfunction getParticleCount(\n viewportWidth: number,\n mobileBreakpoint: number,\n particleCount: number,\n mobileParticleCount: number\n) {\n return viewportWidth < mobileBreakpoint ? mobileParticleCount : particleCount\n}\n\n/**\n * Resolves the devicePixelRatio applied to the canvas.\n */\nfunction getDpr(devicePixelRatio: number, highDpi: boolean, maxDpr: number) {\n return highDpi ? Math.min(devicePixelRatio || 1, maxDpr) : 1\n}\n\n/**\n * Computes the mouse-driven rotation offset around the canvas center.\n */\nfunction getTargetRotation(\n clientX: number,\n centerX: number,\n sensitivity: number\n) {\n return (clientX - centerX) * sensitivity\n}\n\n/**\n * Interpolates the current rotation towards the target rotation.\n */\nfunction smoothRotation(current: number, target: number, smoothing: number) {\n return current + (target - current) * smoothing\n}\n\n/**\n * Projects a particle without any motion (used for the reduced-motion static\n * frame).\n */\nfunction projectParticle(\n particle: Particle,\n options: {\n width: number\n height: number\n fov: number\n perspectiveDistance: number\n }\n) {\n const scale = options.fov / (options.fov + options.perspectiveDistance)\n\n particle.screenX =\n options.width / 2 + Math.cos(particle.angle) * particle.radius * scale\n particle.screenY = options.height / 2 + particle.y * scale\n particle.projectedScale = scale\n}\n\n/**\n * Advances a particle by one frame: applies rotation, floating and the\n * perspective projection, and respawns the particle at the bottom when it\n * leaves the top edge.\n */\nfunction updateParticle(\n particle: Particle,\n options: {\n width: number\n height: number\n rotation: number\n floatSpeed: number\n radiusScale: number\n fov: number\n perspectiveDistance: number\n depthRange: number\n }\n) {\n particle.angle += particle.speed + options.rotation\n particle.y -= options.floatSpeed\n\n if (particle.y < -options.height) {\n particle.y = options.height\n particle.radius = Math.random() * options.width * options.radiusScale\n }\n\n const scale =\n options.fov /\n (options.fov +\n options.perspectiveDistance +\n Math.sin(particle.angle) * options.depthRange)\n\n particle.screenX =\n options.width / 2 + Math.cos(particle.angle) * particle.radius * scale\n particle.screenY = options.height / 2 + particle.y * scale\n particle.projectedScale = scale\n}\n\nexport function Floating3DParticles({\n particleCount = 400,\n mobileParticleCount = 80,\n mobileBreakpoint = 768,\n color = DEFAULT_COLOR,\n minParticleSize = 3,\n maxParticleSize = 8,\n minOpacity = 0.1,\n maxOpacity = 0.5,\n rotationSpeed = 0.002,\n floatSpeed = 0.8,\n radiusScale = 1.2,\n fov = 400,\n perspectiveDistance = 500,\n depthRange = 200,\n mouseInteraction = true,\n mouseSensitivity = 0.0001,\n smoothMouse = true,\n mouseSmoothing = 0.08,\n highDpi = true,\n maxDpr = 2,\n pauseWhenHidden = true,\n respectReducedMotion = true,\n background = \"transparent\",\n zIndex = 0,\n className,\n style,\n ...canvasProps\n}: Floating3DParticlesProps) {\n const canvasRef = React.useRef(null)\n const animationFrameRef = React.useRef(null)\n const resizeObserverRef = React.useRef(null)\n const visibilityObserverRef = React.useRef(null)\n const mountedRef = React.useRef(false)\n const pausedRef = React.useRef(false)\n const reducedMotionRef = React.useRef(false)\n const targetRotationRef = React.useRef(0)\n const currentRotationRef = React.useRef(0)\n\n React.useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return\n\n mountedRef.current = true\n\n let width = 0\n let height = 0\n let dpr = 1\n let particles: Particle[] = []\n let staticFrameDrawn = false\n\n const reducedMotionQuery = window.matchMedia(\n \"(prefers-reduced-motion: reduce)\"\n )\n\n const updateReducedMotion = () => {\n reducedMotionRef.current =\n respectReducedMotion && reducedMotionQuery.matches\n }\n\n updateReducedMotion()\n\n const resize = () => {\n const rect = canvas.getBoundingClientRect()\n width = Math.max(1, Math.round(rect.width))\n height = Math.max(1, Math.round(rect.height))\n\n dpr = getDpr(window.devicePixelRatio || 1, highDpi, maxDpr)\n\n canvas.width = Math.round(width * dpr)\n canvas.height = Math.round(height * dpr)\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n\n const count = getParticleCount(\n window.innerWidth,\n mobileBreakpoint,\n particleCount,\n mobileParticleCount\n )\n\n particles = Array.from({ length: Math.max(0, count) }, () =>\n createParticle(width, height, {\n radiusScale,\n minParticleSize,\n maxParticleSize,\n minOpacity,\n maxOpacity,\n })\n )\n\n staticFrameDrawn = false\n }\n\n const drawParticle = (particle: Particle) => {\n const alpha = particle.opacity * Math.min(1, particle.projectedScale)\n const size = particle.size * particle.projectedScale\n\n if (size <= 0) return\n\n ctx.beginPath()\n ctx.fillStyle = hexToRgba(color, alpha)\n ctx.arc(particle.screenX, particle.screenY, size, 0, Math.PI * 2)\n ctx.fill()\n }\n\n const clear = () => {\n ctx.clearRect(0, 0, width, height)\n }\n\n const renderStaticFrame = () => {\n clear()\n for (const particle of particles) {\n projectParticle(particle, { width, height, fov, perspectiveDistance })\n drawParticle(particle)\n }\n }\n\n const render = () => {\n if (!mountedRef.current) return\n\n if (pausedRef.current) {\n animationFrameRef.current = requestAnimationFrame(render)\n return\n }\n\n // Reduced motion: draw a single static frame and stop.\n if (reducedMotionRef.current) {\n if (!staticFrameDrawn) {\n staticFrameDrawn = true\n renderStaticFrame()\n }\n return\n }\n\n clear()\n\n currentRotationRef.current = smoothMouse\n ? smoothRotation(\n currentRotationRef.current,\n targetRotationRef.current,\n mouseSmoothing\n )\n : targetRotationRef.current\n\n const rotation = rotationSpeed + currentRotationRef.current\n\n for (const particle of particles) {\n updateParticle(particle, {\n width,\n height,\n rotation,\n floatSpeed,\n radiusScale,\n fov,\n perspectiveDistance,\n depthRange,\n })\n }\n\n // Painter's algorithm: draw distant particles first.\n particles.sort((a, b) => a.projectedScale - b.projectedScale)\n\n for (const particle of particles) {\n drawParticle(particle)\n }\n\n animationFrameRef.current = requestAnimationFrame(render)\n }\n\n const handleMouseMove = (event: MouseEvent) => {\n if (!mouseInteraction) return\n\n const rect = canvas.getBoundingClientRect()\n const centerX = rect.left + rect.width / 2\n targetRotationRef.current = getTargetRotation(\n event.clientX,\n centerX,\n mouseSensitivity\n )\n }\n\n const resetMouseRotation = () => {\n targetRotationRef.current = 0\n }\n\n const handleVisibilityChange = () => {\n if (pauseWhenHidden) {\n pausedRef.current = document.hidden\n }\n }\n\n if (typeof ResizeObserver !== \"undefined\") {\n const observer = new ResizeObserver(resize)\n resizeObserverRef.current = observer\n observer.observe(canvas)\n } else {\n window.addEventListener(\"resize\", resize)\n }\n\n if (pauseWhenHidden && typeof IntersectionObserver !== \"undefined\") {\n const observer = new IntersectionObserver(\n (entries) => {\n const entry = entries[0]\n if (!entry) return\n pausedRef.current = document.hidden || !entry.isIntersecting\n },\n { threshold: 0 }\n )\n visibilityObserverRef.current = observer\n observer.observe(canvas)\n }\n\n // Mouse listeners are attached to window so the canvas can keep\n // `pointer-events-none` and never block clicks on the content above it.\n if (mouseInteraction) {\n window.addEventListener(\"mousemove\", handleMouseMove)\n window.addEventListener(\"blur\", resetMouseRotation)\n }\n document.addEventListener(\"visibilitychange\", handleVisibilityChange)\n reducedMotionQuery.addEventListener(\"change\", updateReducedMotion)\n\n resize()\n animationFrameRef.current = requestAnimationFrame(render)\n\n return () => {\n mountedRef.current = false\n\n if (animationFrameRef.current !== null) {\n cancelAnimationFrame(animationFrameRef.current)\n animationFrameRef.current = null\n }\n\n resizeObserverRef.current?.disconnect()\n resizeObserverRef.current = null\n visibilityObserverRef.current?.disconnect()\n visibilityObserverRef.current = null\n\n if (typeof ResizeObserver === \"undefined\") {\n window.removeEventListener(\"resize\", resize)\n }\n\n if (mouseInteraction) {\n window.removeEventListener(\"mousemove\", handleMouseMove)\n window.removeEventListener(\"blur\", resetMouseRotation)\n }\n document.removeEventListener(\"visibilitychange\", handleVisibilityChange)\n reducedMotionQuery.removeEventListener(\"change\", updateReducedMotion)\n }\n }, [\n particleCount,\n mobileParticleCount,\n mobileBreakpoint,\n color,\n minParticleSize,\n maxParticleSize,\n minOpacity,\n maxOpacity,\n rotationSpeed,\n floatSpeed,\n radiusScale,\n fov,\n perspectiveDistance,\n depthRange,\n mouseInteraction,\n mouseSensitivity,\n smoothMouse,\n mouseSmoothing,\n highDpi,\n maxDpr,\n pauseWhenHidden,\n respectReducedMotion,\n ])\n\n return (\n \n )\n}\n\nexport default Floating3DParticles\n",
+ "type": "registry:ui"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json
index 4662fd09b..618e1579e 100644
--- a/apps/www/public/r/registry.json
+++ b/apps/www/public/r/registry.json
@@ -1365,6 +1365,18 @@
}
]
},
+ {
+ "name": "floating-3d-particles",
+ "type": "registry:ui",
+ "title": "Floating 3D Particles",
+ "description": "A canvas-based pseudo-3D particle field with perspective projection, rotation and mouse interaction.",
+ "files": [
+ {
+ "path": "registry/magicui/floating-3d-particles.tsx",
+ "type": "registry:ui"
+ }
+ ]
+ },
{
"name": "magic-card-demo",
"type": "registry:example",
@@ -3955,6 +3967,36 @@
}
]
},
+ {
+ "name": "floating-3d-particles-demo",
+ "type": "registry:example",
+ "title": "Floating 3D Particles Demo",
+ "description": "Example showing a floating 3D particle field behind hero content.",
+ "registryDependencies": [
+ "@magicui/floating-3d-particles"
+ ],
+ "files": [
+ {
+ "path": "registry/example/floating-3d-particles-demo.tsx",
+ "type": "registry:example"
+ }
+ ]
+ },
+ {
+ "name": "floating-3d-particles-demo-2",
+ "type": "registry:example",
+ "title": "Floating 3D Particles Demo 2",
+ "description": "Example showing the particle field layered behind hero content.",
+ "registryDependencies": [
+ "@magicui/floating-3d-particles"
+ ],
+ "files": [
+ {
+ "path": "registry/example/floating-3d-particles-demo-2.tsx",
+ "type": "registry:example"
+ }
+ ]
+ },
{
"name": "utils",
"type": "registry:lib",
diff --git a/apps/www/public/registry.json b/apps/www/public/registry.json
index 4662fd09b..618e1579e 100644
--- a/apps/www/public/registry.json
+++ b/apps/www/public/registry.json
@@ -1365,6 +1365,18 @@
}
]
},
+ {
+ "name": "floating-3d-particles",
+ "type": "registry:ui",
+ "title": "Floating 3D Particles",
+ "description": "A canvas-based pseudo-3D particle field with perspective projection, rotation and mouse interaction.",
+ "files": [
+ {
+ "path": "registry/magicui/floating-3d-particles.tsx",
+ "type": "registry:ui"
+ }
+ ]
+ },
{
"name": "magic-card-demo",
"type": "registry:example",
@@ -3955,6 +3967,36 @@
}
]
},
+ {
+ "name": "floating-3d-particles-demo",
+ "type": "registry:example",
+ "title": "Floating 3D Particles Demo",
+ "description": "Example showing a floating 3D particle field behind hero content.",
+ "registryDependencies": [
+ "@magicui/floating-3d-particles"
+ ],
+ "files": [
+ {
+ "path": "registry/example/floating-3d-particles-demo.tsx",
+ "type": "registry:example"
+ }
+ ]
+ },
+ {
+ "name": "floating-3d-particles-demo-2",
+ "type": "registry:example",
+ "title": "Floating 3D Particles Demo 2",
+ "description": "Example showing the particle field layered behind hero content.",
+ "registryDependencies": [
+ "@magicui/floating-3d-particles"
+ ],
+ "files": [
+ {
+ "path": "registry/example/floating-3d-particles-demo-2.tsx",
+ "type": "registry:example"
+ }
+ ]
+ },
{
"name": "utils",
"type": "registry:lib",
diff --git a/apps/www/registry.json b/apps/www/registry.json
index 4662fd09b..618e1579e 100644
--- a/apps/www/registry.json
+++ b/apps/www/registry.json
@@ -1365,6 +1365,18 @@
}
]
},
+ {
+ "name": "floating-3d-particles",
+ "type": "registry:ui",
+ "title": "Floating 3D Particles",
+ "description": "A canvas-based pseudo-3D particle field with perspective projection, rotation and mouse interaction.",
+ "files": [
+ {
+ "path": "registry/magicui/floating-3d-particles.tsx",
+ "type": "registry:ui"
+ }
+ ]
+ },
{
"name": "magic-card-demo",
"type": "registry:example",
@@ -3955,6 +3967,36 @@
}
]
},
+ {
+ "name": "floating-3d-particles-demo",
+ "type": "registry:example",
+ "title": "Floating 3D Particles Demo",
+ "description": "Example showing a floating 3D particle field behind hero content.",
+ "registryDependencies": [
+ "@magicui/floating-3d-particles"
+ ],
+ "files": [
+ {
+ "path": "registry/example/floating-3d-particles-demo.tsx",
+ "type": "registry:example"
+ }
+ ]
+ },
+ {
+ "name": "floating-3d-particles-demo-2",
+ "type": "registry:example",
+ "title": "Floating 3D Particles Demo 2",
+ "description": "Example showing the particle field layered behind hero content.",
+ "registryDependencies": [
+ "@magicui/floating-3d-particles"
+ ],
+ "files": [
+ {
+ "path": "registry/example/floating-3d-particles-demo-2.tsx",
+ "type": "registry:example"
+ }
+ ]
+ },
{
"name": "utils",
"type": "registry:lib",
diff --git a/apps/www/registry/__index__.tsx b/apps/www/registry/__index__.tsx
index dfc823153..97f29b6cf 100644
--- a/apps/www/registry/__index__.tsx
+++ b/apps/www/registry/__index__.tsx
@@ -1324,6 +1324,23 @@ export const Index: Record = {
}),
meta: undefined,
},
+ "floating-3d-particles": {
+ name: "floating-3d-particles",
+ description: "A canvas-based pseudo-3D particle field with perspective projection, rotation and mouse interaction.",
+ type: "registry:ui",
+ registryDependencies: undefined,
+ files: [{
+ path: "registry/magicui/floating-3d-particles.tsx",
+ type: "registry:ui",
+ target: ""
+ }],
+ component: React.lazy(async () => {
+ const mod = await import("@/registry/magicui/floating-3d-particles.tsx")
+ const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') ?? item.name
+ return { default: mod.default ?? mod[exportName] }
+ }),
+ meta: undefined,
+ },
"magic-card-demo": {
name: "magic-card-demo",
description: "Example showing a spotlight effect that follows your mouse cursor and highlights borders on hover.",
@@ -4184,6 +4201,40 @@ export const Index: Record = {
}),
meta: undefined,
},
+ "floating-3d-particles-demo": {
+ name: "floating-3d-particles-demo",
+ description: "Example showing a floating 3D particle field behind hero content.",
+ type: "registry:example",
+ registryDependencies: ["@magicui/floating-3d-particles"],
+ files: [{
+ path: "registry/example/floating-3d-particles-demo.tsx",
+ type: "registry:example",
+ target: ""
+ }],
+ component: React.lazy(async () => {
+ const mod = await import("@/registry/example/floating-3d-particles-demo.tsx")
+ const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') ?? item.name
+ return { default: mod.default ?? mod[exportName] }
+ }),
+ meta: undefined,
+ },
+ "floating-3d-particles-demo-2": {
+ name: "floating-3d-particles-demo-2",
+ description: "Example showing the particle field layered behind hero content.",
+ type: "registry:example",
+ registryDependencies: ["@magicui/floating-3d-particles"],
+ files: [{
+ path: "registry/example/floating-3d-particles-demo-2.tsx",
+ type: "registry:example",
+ target: ""
+ }],
+ component: React.lazy(async () => {
+ const mod = await import("@/registry/example/floating-3d-particles-demo-2.tsx")
+ const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') ?? item.name
+ return { default: mod.default ?? mod[exportName] }
+ }),
+ meta: undefined,
+ },
"utils": {
name: "utils",
description: "",
diff --git a/apps/www/registry/example/floating-3d-particles-demo-2.tsx b/apps/www/registry/example/floating-3d-particles-demo-2.tsx
new file mode 100644
index 000000000..4ab9c1ff2
--- /dev/null
+++ b/apps/www/registry/example/floating-3d-particles-demo-2.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import { ArrowRight } from "lucide-react"
+
+import { Floating3DParticles } from "@/registry/magicui/floating-3d-particles"
+
+export default function Component() {
+ return (
+
+
+
+
+
+ Build something magical
+
+
+ A pseudo-3D particle background that stays behind your content and
+ follows the mouse.
+
+
+ Get Started
+
+
+
+
+ )
+}
diff --git a/apps/www/registry/example/floating-3d-particles-demo.tsx b/apps/www/registry/example/floating-3d-particles-demo.tsx
new file mode 100644
index 000000000..ccdc8d75a
--- /dev/null
+++ b/apps/www/registry/example/floating-3d-particles-demo.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import { Floating3DParticles } from "@/registry/magicui/floating-3d-particles"
+
+export default function Component() {
+ return (
+
+
+
+ )
+}
diff --git a/apps/www/registry/magicui/floating-3d-particles.tsx b/apps/www/registry/magicui/floating-3d-particles.tsx
new file mode 100644
index 000000000..4125ae383
--- /dev/null
+++ b/apps/www/registry/magicui/floating-3d-particles.tsx
@@ -0,0 +1,591 @@
+"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 (
+
+ )
+}
+
+export default Floating3DParticles
diff --git a/apps/www/registry/registry-examples.ts b/apps/www/registry/registry-examples.ts
index eb8c8ff10..c675f2635 100644
--- a/apps/www/registry/registry-examples.ts
+++ b/apps/www/registry/registry-examples.ts
@@ -2245,4 +2245,32 @@ export const examples: Registry["items"] = [
},
],
},
+ {
+ name: "floating-3d-particles-demo",
+ type: "registry:example",
+ title: "Floating 3D Particles Demo",
+ description:
+ "Example showing a floating 3D particle field behind hero content.",
+ registryDependencies: ["@magicui/floating-3d-particles"],
+ files: [
+ {
+ path: "example/floating-3d-particles-demo.tsx",
+ type: "registry:example",
+ },
+ ],
+ },
+ {
+ name: "floating-3d-particles-demo-2",
+ type: "registry:example",
+ title: "Floating 3D Particles Demo 2",
+ description:
+ "Example showing the particle field layered behind hero content.",
+ registryDependencies: ["@magicui/floating-3d-particles"],
+ files: [
+ {
+ path: "example/floating-3d-particles-demo-2.tsx",
+ type: "registry:example",
+ },
+ ],
+ },
]
diff --git a/apps/www/registry/registry-ui.ts b/apps/www/registry/registry-ui.ts
index d42e6a097..8504c7b80 100644
--- a/apps/www/registry/registry-ui.ts
+++ b/apps/www/registry/registry-ui.ts
@@ -1313,4 +1313,17 @@ export const ui: Registry["items"] = [
},
],
},
+ {
+ name: "floating-3d-particles",
+ type: "registry:ui",
+ title: "Floating 3D Particles",
+ description:
+ "A canvas-based pseudo-3D particle field with perspective projection, rotation and mouse interaction.",
+ files: [
+ {
+ path: "magicui/floating-3d-particles.tsx",
+ type: "registry:ui",
+ },
+ ],
+ },
]