{ "name": "cursor-driven-particle-typography", "type": "registry:ui", "dependencies": [], "registryDependencies": [], "description": "Component for cursor-driven-particle-typography", "files": [ { "path": "components/ui/cursor-driven-particle-typography.tsx", "content": "\"use client\";\n\nimport { cn } from \"@workspace/ui/lib/utils\";\nimport React, { useEffect, useRef } from \"react\";\n\nexport interface CursorDrivenParticleTypographyProps {\n /** Additional CSS classes */\n className?: string;\n /** The text to render */\n text: string;\n /** Font size in pixels */\n fontSize?: number;\n /** Font family */\n fontFamily?: string;\n /** Size of each particle */\n particleSize?: number;\n /** Density of particles (lower number = more particles, minimum 1) */\n particleDensity?: number;\n /** How strongly the cursor pushes particles away */\n dispersionStrength?: number;\n /** Speed at which particles return to origin */\n returnSpeed?: number;\n /** Custom color for particles. Overrides inherited text color if set. */\n color?: string;\n}\n\nclass Particle {\n x: number;\n y: number;\n originX: number;\n originY: number;\n vx: number;\n vy: number;\n size: number;\n color: string;\n dispersion: number;\n returnSpd: number;\n\n constructor(\n x: number,\n y: number,\n size: number,\n color: string,\n dispersion: number,\n returnSpd: number\n ) {\n this.x = x + (Math.random() - 0.5) * 10; // start with slight randomness\n this.y = y + (Math.random() - 0.5) * 10;\n this.originX = x;\n this.originY = y;\n this.vx = (Math.random() - 0.5) * 5;\n this.vy = (Math.random() - 0.5) * 5;\n this.size = size;\n this.color = color;\n this.dispersion = dispersion;\n this.returnSpd = returnSpd;\n }\n\n update(mouseX: number, mouseY: number) {\n const dx = mouseX - this.x;\n const dy = mouseY - this.y;\n const distance = Math.sqrt(dx * dx + dy * dy);\n\n // Physics interaction with mouse\n const interactionRadius = 120; // 120px interaction radius\n\n if (distance < interactionRadius && mouseX !== -1000 && mouseY !== -1000) {\n const forceDirectionX = dx / distance;\n const forceDirectionY = dy / distance;\n\n const force = (interactionRadius - distance) / interactionRadius;\n\n // Calculate repulsion\n const repulsionX = forceDirectionX * force * this.dispersion;\n const repulsionY = forceDirectionY * force * this.dispersion;\n\n this.vx -= repulsionX;\n this.vy -= repulsionY;\n }\n\n // Return to origin (spring physics)\n this.vx += (this.originX - this.x) * this.returnSpd;\n this.vy += (this.originY - this.y) * this.returnSpd;\n\n // Friction\n this.vx *= 0.85;\n this.vy *= 0.85;\n\n // Add subtle noise/jitter when close to origin\n const distToOrigin = Math.sqrt(\n Math.pow(this.x - this.originX, 2) + Math.pow(this.y - this.originY, 2)\n );\n if (distToOrigin < 1 && Math.random() > 0.95) {\n this.vx += (Math.random() - 0.5) * 0.2;\n this.vy += (Math.random() - 0.5) * 0.2;\n }\n\n this.x += this.vx;\n this.y += this.vy;\n }\n\n draw(ctx: CanvasRenderingContext2D) {\n ctx.fillStyle = this.color;\n ctx.beginPath();\n ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);\n ctx.fill();\n }\n}\n\nexport function CursorDrivenParticleTypography({\n className,\n text,\n fontSize = 120,\n fontFamily = \"Inter, sans-serif\",\n particleSize = 1.5,\n particleDensity = 6,\n dispersionStrength = 15,\n returnSpeed = 0.08,\n color,\n}: CursorDrivenParticleTypographyProps) {\n const canvasRef = useRef(null);\n const containerRef = useRef(null);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n if (!ctx) return;\n\n let animationFrameId: number;\n let particles: Particle[] = [];\n\n let mouseX = -1000;\n let mouseY = -1000;\n\n let containerWidth = 0;\n let containerHeight = 0;\n\n const init = () => {\n const container = containerRef.current;\n if (!container) return;\n\n containerWidth = container.clientWidth;\n containerHeight = container.clientHeight;\n\n const dpr = window.devicePixelRatio || 1;\n canvas.width = containerWidth * dpr;\n canvas.height = containerHeight * dpr;\n canvas.style.width = `${containerWidth}px`;\n canvas.style.height = `${containerHeight}px`;\n\n ctx.scale(dpr, dpr);\n\n // Determine text color\n const computedStyle = window.getComputedStyle(container);\n const textColor = color || computedStyle.color || \"#000000\";\n\n ctx.clearRect(0, 0, containerWidth, containerHeight);\n\n // Draw text to generate pixel map\n ctx.fillStyle = textColor;\n // Responsive font size based on container width if text is large\n const effectiveFontSize = Math.min(fontSize, containerWidth * 0.15);\n ctx.font = `bold ${effectiveFontSize}px ${fontFamily}`;\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n\n // Draw standard text first to measure it\n ctx.fillText(text, containerWidth / 2, containerHeight / 2);\n\n // Get pixel data\n const textCoordinates = ctx.getImageData(0, 0, canvas.width, canvas.height);\n particles = [];\n\n // Create particles from text pixels\n // Step by density multiplied by dpr\n const step = Math.max(1, Math.floor(particleDensity * dpr));\n for (let y = 0; y < textCoordinates.height; y += step) {\n for (let x = 0; x < textCoordinates.width; x += step) {\n const index = (y * textCoordinates.width + x) * 4;\n const alpha = textCoordinates.data[index + 3] || 0;\n\n if (alpha > 128) {\n particles.push(\n new Particle(\n x / dpr,\n y / dpr,\n particleSize,\n textColor,\n dispersionStrength,\n returnSpeed\n )\n );\n }\n }\n }\n };\n\n const animate = () => {\n ctx.clearRect(0, 0, containerWidth, containerHeight);\n\n particles.forEach((particle) => {\n particle.update(mouseX, mouseY);\n particle.draw(ctx);\n });\n animationFrameId = requestAnimationFrame(animate);\n };\n\n const handleMouseMove = (e: MouseEvent) => {\n const rect = canvas.getBoundingClientRect();\n mouseX = e.clientX - rect.left;\n mouseY = e.clientY - rect.top;\n };\n\n const handleMouseLeave = () => {\n mouseX = -1000;\n mouseY = -1000;\n };\n\n const handleResize = () => {\n init();\n };\n\n // Initialize with a short delay to ensure fonts/layout are ready\n const timeoutId = setTimeout(() => {\n init();\n animate();\n }, 100);\n\n const resizeObserver = new ResizeObserver(() => {\n handleResize();\n });\n\n if (containerRef.current) {\n resizeObserver.observe(containerRef.current);\n }\n\n // Re-initialize particles when the theme changes (detects class changes on html tag)\n const themeObserver = new MutationObserver(() => {\n init();\n });\n themeObserver.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\"]\n });\n\n canvas.addEventListener(\"mousemove\", handleMouseMove);\n canvas.addEventListener(\"mouseleave\", handleMouseLeave);\n canvas.addEventListener(\"touchstart\", (e) => {\n if (!e.touches[0]) return;\n const rect = canvas.getBoundingClientRect();\n mouseX = e.touches[0].clientX - rect.left;\n mouseY = e.touches[0].clientY - rect.top;\n });\n canvas.addEventListener(\"touchmove\", (e) => {\n if (!e.touches[0]) return;\n const rect = canvas.getBoundingClientRect();\n mouseX = e.touches[0].clientX - rect.left;\n mouseY = e.touches[0].clientY - rect.top;\n });\n canvas.addEventListener(\"touchend\", handleMouseLeave);\n\n return () => {\n clearTimeout(timeoutId);\n resizeObserver.disconnect();\n themeObserver.disconnect();\n canvas.removeEventListener(\"mousemove\", handleMouseMove);\n canvas.removeEventListener(\"mouseleave\", handleMouseLeave);\n cancelAnimationFrame(animationFrameId);\n };\n }, [text, fontSize, fontFamily, particleSize, particleDensity, dispersionStrength, returnSpeed, color]);\n\n return (\n \n \n \n );\n}\n", "type": "registry:ui" } ] }