From 713937dfc01fdd4a48e013105c0c62f0a3a27d8e Mon Sep 17 00:00:00 2001 From: SamHacker Date: Tue, 21 Jul 2026 20:55:30 +0800 Subject: [PATCH 1/6] feat(icon-cloud): add animation playback toggle for accessibility Add a control button and a prop to toggle its visibility in the IconCloud component to meet accessibility standards. --- apps/www/public/llms-full.txt | 75 +++++++++++++++++++----- apps/www/public/r/icon-cloud.json | 2 +- apps/www/registry/magicui/icon-cloud.tsx | 75 +++++++++++++++++++----- 3 files changed, 121 insertions(+), 31 deletions(-) diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index 6d6b7de04..efe27c33e 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -10300,8 +10300,11 @@ Description: An interactive 3D tag cloud component "use client" import React, { useEffect, useRef, useState } from "react" +import { Pause, Play } from "lucide-react" import { renderToString } from "react-dom/server" +import { Button } from "@/components/ui/button" + interface Icon { x: number y: number @@ -10314,16 +10317,22 @@ interface Icon { interface IconCloudProps { icons?: React.ReactNode[] images?: string[] + showControl?: boolean } function easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3) } -export function IconCloud({ icons, images }: IconCloudProps) { +export function IconCloud({ + icons, + images, + showControl = false, +}: IconCloudProps) { const canvasRef = useRef(null) const [iconPositions, setIconPositions] = useState([]) const [isDragging, setIsDragging] = useState(false) + const [isPaused, setIsPaused] = useState(false) const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 }) const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) const [targetRotation, setTargetRotation] = useState<{ @@ -10340,6 +10349,21 @@ export function IconCloud({ icons, images }: IconCloudProps) { const iconCanvasesRef = useRef([]) const imagesLoadedRef = useRef([]) + // Pause animation if user prefers reduced motion + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)") + if (mediaQuery.matches) { + setIsPaused(true) + } + + const handleChange = (e: MediaQueryListEvent) => { + setIsPaused(e.matches) + } + + mediaQuery.addEventListener("change", handleChange) + return () => mediaQuery.removeEventListener("change", handleChange) + }, []) + // Create icon canvases once when icons/images change useEffect(() => { if (!icons && !images) return @@ -10541,7 +10565,7 @@ export function IconCloud({ icons, images }: IconCloudProps) { if (progress >= 1) { setTargetRotation(null) } - } else if (!isDragging) { + } else if (!isDragging && !isPaused) { rotationRef.current = { x: rotationRef.current.x + (dy / canvas.height) * speed, y: rotationRef.current.y + (dx / canvas.width) * speed, @@ -10603,21 +10627,42 @@ export function IconCloud({ icons, images }: IconCloudProps) { cancelAnimationFrame(animationFrameRef.current) } } - }, [icons, images, iconPositions, isDragging, mousePos, targetRotation]) + }, [ + icons, + images, + iconPositions, + isDragging, + isPaused, + mousePos, + targetRotation, + ]) return ( - +
+ + {showControl && ( + + )} +
) } diff --git a/apps/www/public/r/icon-cloud.json b/apps/www/public/r/icon-cloud.json index 15587db8e..771e94476 100644 --- a/apps/www/public/r/icon-cloud.json +++ b/apps/www/public/r/icon-cloud.json @@ -8,7 +8,7 @@ "files": [ { "path": "registry/magicui/icon-cloud.tsx", - "content": "\"use client\"\n\nimport React, { useEffect, useRef, useState } from \"react\"\nimport { renderToString } from \"react-dom/server\"\n\ninterface Icon {\n x: number\n y: number\n z: number\n scale: number\n opacity: number\n id: number\n}\n\ninterface IconCloudProps {\n icons?: React.ReactNode[]\n images?: string[]\n}\n\nfunction easeOutCubic(t: number): number {\n return 1 - Math.pow(1 - t, 3)\n}\n\nexport function IconCloud({ icons, images }: IconCloudProps) {\n const canvasRef = useRef(null)\n const [iconPositions, setIconPositions] = useState([])\n const [isDragging, setIsDragging] = useState(false)\n const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 })\n const [mousePos, setMousePos] = useState({ x: 0, y: 0 })\n const [targetRotation, setTargetRotation] = useState<{\n x: number\n y: number\n startX: number\n startY: number\n distance: number\n startTime: number\n duration: number\n } | null>(null)\n const animationFrameRef = useRef(0)\n const rotationRef = useRef({ x: 0, y: 0 })\n const iconCanvasesRef = useRef([])\n const imagesLoadedRef = useRef([])\n\n // Create icon canvases once when icons/images change\n useEffect(() => {\n if (!icons && !images) return\n\n const items = icons ?? images ?? []\n imagesLoadedRef.current = new Array(items.length).fill(false)\n\n const newIconCanvases = items.map((item, index) => {\n const offscreen = document.createElement(\"canvas\")\n offscreen.width = 40\n offscreen.height = 40\n const offCtx = offscreen.getContext(\"2d\")\n\n if (offCtx) {\n if (images) {\n // Handle image URLs directly\n const img = new Image()\n img.crossOrigin = \"anonymous\"\n img.src = items[index] as string\n img.onload = () => {\n offCtx.clearRect(0, 0, offscreen.width, offscreen.height)\n\n // Create circular clipping path\n offCtx.beginPath()\n offCtx.arc(20, 20, 20, 0, Math.PI * 2)\n offCtx.closePath()\n offCtx.clip()\n\n // Draw the image\n offCtx.drawImage(img, 0, 0, 40, 40)\n\n imagesLoadedRef.current[index] = true\n }\n } else {\n // Handle SVG icons\n offCtx.scale(0.4, 0.4)\n const svgString = renderToString(item as React.ReactElement)\n const img = new Image()\n img.src = \"data:image/svg+xml;base64,\" + btoa(svgString)\n img.onload = () => {\n offCtx.clearRect(0, 0, offscreen.width, offscreen.height)\n offCtx.drawImage(img, 0, 0)\n imagesLoadedRef.current[index] = true\n }\n }\n }\n return offscreen\n })\n\n iconCanvasesRef.current = newIconCanvases\n }, [icons, images])\n\n // Generate initial icon positions on a sphere\n useEffect(() => {\n const items = icons ?? images ?? []\n const newIcons: Icon[] = []\n const numIcons = items.length || 20\n\n // Fibonacci sphere parameters\n const offset = 2 / numIcons\n const increment = Math.PI * (3 - Math.sqrt(5))\n\n for (let i = 0; i < numIcons; i++) {\n const y = i * offset - 1 + offset / 2\n const r = Math.sqrt(1 - y * y)\n const phi = i * increment\n\n const x = Math.cos(phi) * r\n const z = Math.sin(phi) * r\n\n newIcons.push({\n x: x * 100,\n y: y * 100,\n z: z * 100,\n scale: 1,\n opacity: 1,\n id: i,\n })\n }\n setIconPositions(newIcons)\n }, [icons, images])\n\n // Handle mouse events\n const handleMouseDown = (e: React.MouseEvent) => {\n const rect = canvasRef.current?.getBoundingClientRect()\n if (!rect || !canvasRef.current) return\n\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n\n const ctx = canvasRef.current.getContext(\"2d\")\n if (!ctx) return\n\n iconPositions.forEach((icon) => {\n const cosX = Math.cos(rotationRef.current.x)\n const sinX = Math.sin(rotationRef.current.x)\n const cosY = Math.cos(rotationRef.current.y)\n const sinY = Math.sin(rotationRef.current.y)\n\n const rotatedX = icon.x * cosY - icon.z * sinY\n const rotatedZ = icon.x * sinY + icon.z * cosY\n const rotatedY = icon.y * cosX + rotatedZ * sinX\n\n const screenX = canvasRef.current!.width / 2 + rotatedX\n const screenY = canvasRef.current!.height / 2 + rotatedY\n\n const scale = (rotatedZ + 200) / 300\n const radius = 20 * scale\n const dx = x - screenX\n const dy = y - screenY\n\n if (dx * dx + dy * dy < radius * radius) {\n const targetX = -Math.atan2(\n icon.y,\n Math.sqrt(icon.x * icon.x + icon.z * icon.z)\n )\n const targetY = Math.atan2(icon.x, icon.z)\n\n const currentX = rotationRef.current.x\n const currentY = rotationRef.current.y\n const distance = Math.sqrt(\n Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)\n )\n\n const duration = Math.min(2000, Math.max(800, distance * 1000))\n\n setTargetRotation({\n x: targetX,\n y: targetY,\n startX: currentX,\n startY: currentY,\n distance,\n startTime: performance.now(),\n duration,\n })\n return\n }\n })\n\n setIsDragging(true)\n setLastMousePos({ x: e.clientX, y: e.clientY })\n }\n\n const handleMouseMove = (e: React.MouseEvent) => {\n const rect = canvasRef.current?.getBoundingClientRect()\n if (rect) {\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n setMousePos({ x, y })\n }\n\n if (isDragging) {\n const deltaX = e.clientX - lastMousePos.x\n const deltaY = e.clientY - lastMousePos.y\n\n rotationRef.current = {\n x: rotationRef.current.x + deltaY * 0.002,\n y: rotationRef.current.y + deltaX * 0.002,\n }\n\n setLastMousePos({ x: e.clientX, y: e.clientY })\n }\n }\n\n const handleMouseUp = () => {\n setIsDragging(false)\n }\n\n // Animation and rendering\n useEffect(() => {\n const canvas = canvasRef.current\n const ctx = canvas?.getContext(\"2d\")\n if (canvas && ctx) {\n const animate = () => {\n ctx.clearRect(0, 0, canvas.width, canvas.height)\n\n const centerX = canvas.width / 2\n const centerY = canvas.height / 2\n const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY)\n const dx = mousePos.x - centerX\n const dy = mousePos.y - centerY\n const distance = Math.sqrt(dx * dx + dy * dy)\n const speed = 0.003 + (distance / maxDistance) * 0.01\n\n if (targetRotation) {\n const elapsed = performance.now() - targetRotation.startTime\n const progress = Math.min(1, elapsed / targetRotation.duration)\n const easedProgress = easeOutCubic(progress)\n\n rotationRef.current = {\n x:\n targetRotation.startX +\n (targetRotation.x - targetRotation.startX) * easedProgress,\n y:\n targetRotation.startY +\n (targetRotation.y - targetRotation.startY) * easedProgress,\n }\n\n if (progress >= 1) {\n setTargetRotation(null)\n }\n } else if (!isDragging) {\n rotationRef.current = {\n x: rotationRef.current.x + (dy / canvas.height) * speed,\n y: rotationRef.current.y + (dx / canvas.width) * speed,\n }\n }\n\n iconPositions.forEach((icon, index) => {\n const cosX = Math.cos(rotationRef.current.x)\n const sinX = Math.sin(rotationRef.current.x)\n const cosY = Math.cos(rotationRef.current.y)\n const sinY = Math.sin(rotationRef.current.y)\n\n const rotatedX = icon.x * cosY - icon.z * sinY\n const rotatedZ = icon.x * sinY + icon.z * cosY\n const rotatedY = icon.y * cosX + rotatedZ * sinX\n\n const scale = (rotatedZ + 200) / 300\n const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200))\n\n ctx.save()\n ctx.translate(\n canvas.width / 2 + rotatedX,\n canvas.height / 2 + rotatedY\n )\n ctx.scale(scale, scale)\n ctx.globalAlpha = opacity\n\n if (icons || images) {\n // Only try to render icons/images if they exist\n if (\n iconCanvasesRef.current[index] &&\n imagesLoadedRef.current[index]\n ) {\n ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40)\n }\n } else {\n // Show numbered circles if no icons/images are provided\n ctx.beginPath()\n ctx.arc(0, 0, 20, 0, Math.PI * 2)\n ctx.fillStyle = \"#4444ff\"\n ctx.fill()\n ctx.fillStyle = \"white\"\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.font = \"16px Arial\"\n ctx.fillText(`${icon.id + 1}`, 0, 0)\n }\n\n ctx.restore()\n })\n animationFrameRef.current = requestAnimationFrame(animate)\n }\n\n animate()\n }\n\n return () => {\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current)\n }\n }\n }, [icons, images, iconPositions, isDragging, mousePos, targetRotation])\n\n return (\n \n )\n}\n", + "content": "\"use client\"\n\nimport React, { useEffect, useRef, useState } from \"react\"\nimport { Pause, Play } from \"lucide-react\"\nimport { renderToString } from \"react-dom/server\"\n\nimport { Button } from \"@/components/ui/button\"\n\ninterface Icon {\n x: number\n y: number\n z: number\n scale: number\n opacity: number\n id: number\n}\n\ninterface IconCloudProps {\n icons?: React.ReactNode[]\n images?: string[]\n showControl?: boolean\n}\n\nfunction easeOutCubic(t: number): number {\n return 1 - Math.pow(1 - t, 3)\n}\n\nexport function IconCloud({\n icons,\n images,\n showControl = false,\n}: IconCloudProps) {\n const canvasRef = useRef(null)\n const [iconPositions, setIconPositions] = useState([])\n const [isDragging, setIsDragging] = useState(false)\n const [isPaused, setIsPaused] = useState(false)\n const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 })\n const [mousePos, setMousePos] = useState({ x: 0, y: 0 })\n const [targetRotation, setTargetRotation] = useState<{\n x: number\n y: number\n startX: number\n startY: number\n distance: number\n startTime: number\n duration: number\n } | null>(null)\n const animationFrameRef = useRef(0)\n const rotationRef = useRef({ x: 0, y: 0 })\n const iconCanvasesRef = useRef([])\n const imagesLoadedRef = useRef([])\n\n // Pause animation if user prefers reduced motion\n useEffect(() => {\n const mediaQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n if (mediaQuery.matches) {\n setIsPaused(true)\n }\n\n const handleChange = (e: MediaQueryListEvent) => {\n setIsPaused(e.matches)\n }\n\n mediaQuery.addEventListener(\"change\", handleChange)\n return () => mediaQuery.removeEventListener(\"change\", handleChange)\n }, [])\n\n // Create icon canvases once when icons/images change\n useEffect(() => {\n if (!icons && !images) return\n\n const items = icons ?? images ?? []\n imagesLoadedRef.current = new Array(items.length).fill(false)\n\n const newIconCanvases = items.map((item, index) => {\n const offscreen = document.createElement(\"canvas\")\n offscreen.width = 40\n offscreen.height = 40\n const offCtx = offscreen.getContext(\"2d\")\n\n if (offCtx) {\n if (images) {\n // Handle image URLs directly\n const img = new Image()\n img.crossOrigin = \"anonymous\"\n img.src = items[index] as string\n img.onload = () => {\n offCtx.clearRect(0, 0, offscreen.width, offscreen.height)\n\n // Create circular clipping path\n offCtx.beginPath()\n offCtx.arc(20, 20, 20, 0, Math.PI * 2)\n offCtx.closePath()\n offCtx.clip()\n\n // Draw the image\n offCtx.drawImage(img, 0, 0, 40, 40)\n\n imagesLoadedRef.current[index] = true\n }\n } else {\n // Handle SVG icons\n offCtx.scale(0.4, 0.4)\n const svgString = renderToString(item as React.ReactElement)\n const img = new Image()\n img.src = \"data:image/svg+xml;base64,\" + btoa(svgString)\n img.onload = () => {\n offCtx.clearRect(0, 0, offscreen.width, offscreen.height)\n offCtx.drawImage(img, 0, 0)\n imagesLoadedRef.current[index] = true\n }\n }\n }\n return offscreen\n })\n\n iconCanvasesRef.current = newIconCanvases\n }, [icons, images])\n\n // Generate initial icon positions on a sphere\n useEffect(() => {\n const items = icons ?? images ?? []\n const newIcons: Icon[] = []\n const numIcons = items.length || 20\n\n // Fibonacci sphere parameters\n const offset = 2 / numIcons\n const increment = Math.PI * (3 - Math.sqrt(5))\n\n for (let i = 0; i < numIcons; i++) {\n const y = i * offset - 1 + offset / 2\n const r = Math.sqrt(1 - y * y)\n const phi = i * increment\n\n const x = Math.cos(phi) * r\n const z = Math.sin(phi) * r\n\n newIcons.push({\n x: x * 100,\n y: y * 100,\n z: z * 100,\n scale: 1,\n opacity: 1,\n id: i,\n })\n }\n setIconPositions(newIcons)\n }, [icons, images])\n\n // Handle mouse events\n const handleMouseDown = (e: React.MouseEvent) => {\n const rect = canvasRef.current?.getBoundingClientRect()\n if (!rect || !canvasRef.current) return\n\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n\n const ctx = canvasRef.current.getContext(\"2d\")\n if (!ctx) return\n\n iconPositions.forEach((icon) => {\n const cosX = Math.cos(rotationRef.current.x)\n const sinX = Math.sin(rotationRef.current.x)\n const cosY = Math.cos(rotationRef.current.y)\n const sinY = Math.sin(rotationRef.current.y)\n\n const rotatedX = icon.x * cosY - icon.z * sinY\n const rotatedZ = icon.x * sinY + icon.z * cosY\n const rotatedY = icon.y * cosX + rotatedZ * sinX\n\n const screenX = canvasRef.current!.width / 2 + rotatedX\n const screenY = canvasRef.current!.height / 2 + rotatedY\n\n const scale = (rotatedZ + 200) / 300\n const radius = 20 * scale\n const dx = x - screenX\n const dy = y - screenY\n\n if (dx * dx + dy * dy < radius * radius) {\n const targetX = -Math.atan2(\n icon.y,\n Math.sqrt(icon.x * icon.x + icon.z * icon.z)\n )\n const targetY = Math.atan2(icon.x, icon.z)\n\n const currentX = rotationRef.current.x\n const currentY = rotationRef.current.y\n const distance = Math.sqrt(\n Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)\n )\n\n const duration = Math.min(2000, Math.max(800, distance * 1000))\n\n setTargetRotation({\n x: targetX,\n y: targetY,\n startX: currentX,\n startY: currentY,\n distance,\n startTime: performance.now(),\n duration,\n })\n return\n }\n })\n\n setIsDragging(true)\n setLastMousePos({ x: e.clientX, y: e.clientY })\n }\n\n const handleMouseMove = (e: React.MouseEvent) => {\n const rect = canvasRef.current?.getBoundingClientRect()\n if (rect) {\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n setMousePos({ x, y })\n }\n\n if (isDragging) {\n const deltaX = e.clientX - lastMousePos.x\n const deltaY = e.clientY - lastMousePos.y\n\n rotationRef.current = {\n x: rotationRef.current.x + deltaY * 0.002,\n y: rotationRef.current.y + deltaX * 0.002,\n }\n\n setLastMousePos({ x: e.clientX, y: e.clientY })\n }\n }\n\n const handleMouseUp = () => {\n setIsDragging(false)\n }\n\n // Animation and rendering\n useEffect(() => {\n const canvas = canvasRef.current\n const ctx = canvas?.getContext(\"2d\")\n if (canvas && ctx) {\n const animate = () => {\n ctx.clearRect(0, 0, canvas.width, canvas.height)\n\n const centerX = canvas.width / 2\n const centerY = canvas.height / 2\n const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY)\n const dx = mousePos.x - centerX\n const dy = mousePos.y - centerY\n const distance = Math.sqrt(dx * dx + dy * dy)\n const speed = 0.003 + (distance / maxDistance) * 0.01\n\n if (targetRotation) {\n const elapsed = performance.now() - targetRotation.startTime\n const progress = Math.min(1, elapsed / targetRotation.duration)\n const easedProgress = easeOutCubic(progress)\n\n rotationRef.current = {\n x:\n targetRotation.startX +\n (targetRotation.x - targetRotation.startX) * easedProgress,\n y:\n targetRotation.startY +\n (targetRotation.y - targetRotation.startY) * easedProgress,\n }\n\n if (progress >= 1) {\n setTargetRotation(null)\n }\n } else if (!isDragging && !isPaused) {\n rotationRef.current = {\n x: rotationRef.current.x + (dy / canvas.height) * speed,\n y: rotationRef.current.y + (dx / canvas.width) * speed,\n }\n }\n\n iconPositions.forEach((icon, index) => {\n const cosX = Math.cos(rotationRef.current.x)\n const sinX = Math.sin(rotationRef.current.x)\n const cosY = Math.cos(rotationRef.current.y)\n const sinY = Math.sin(rotationRef.current.y)\n\n const rotatedX = icon.x * cosY - icon.z * sinY\n const rotatedZ = icon.x * sinY + icon.z * cosY\n const rotatedY = icon.y * cosX + rotatedZ * sinX\n\n const scale = (rotatedZ + 200) / 300\n const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200))\n\n ctx.save()\n ctx.translate(\n canvas.width / 2 + rotatedX,\n canvas.height / 2 + rotatedY\n )\n ctx.scale(scale, scale)\n ctx.globalAlpha = opacity\n\n if (icons || images) {\n // Only try to render icons/images if they exist\n if (\n iconCanvasesRef.current[index] &&\n imagesLoadedRef.current[index]\n ) {\n ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40)\n }\n } else {\n // Show numbered circles if no icons/images are provided\n ctx.beginPath()\n ctx.arc(0, 0, 20, 0, Math.PI * 2)\n ctx.fillStyle = \"#4444ff\"\n ctx.fill()\n ctx.fillStyle = \"white\"\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.font = \"16px Arial\"\n ctx.fillText(`${icon.id + 1}`, 0, 0)\n }\n\n ctx.restore()\n })\n animationFrameRef.current = requestAnimationFrame(animate)\n }\n\n animate()\n }\n\n return () => {\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current)\n }\n }\n }, [\n icons,\n images,\n iconPositions,\n isDragging,\n isPaused,\n mousePos,\n targetRotation,\n ])\n\n return (\n
\n \n {showControl && (\n setIsPaused(!isPaused)}\n aria-label={isPaused ? \"Play Animation\" : \"Pause Animation\"}\n className=\"absolute top-2 right-2\"\n >\n {isPaused ? : }\n \n )}\n
\n )\n}\n", "type": "registry:ui" } ] diff --git a/apps/www/registry/magicui/icon-cloud.tsx b/apps/www/registry/magicui/icon-cloud.tsx index 54fc16a60..8db352250 100644 --- a/apps/www/registry/magicui/icon-cloud.tsx +++ b/apps/www/registry/magicui/icon-cloud.tsx @@ -1,8 +1,11 @@ "use client" import React, { useEffect, useRef, useState } from "react" +import { Pause, Play } from "lucide-react" import { renderToString } from "react-dom/server" +import { Button } from "@/components/ui/button" + interface Icon { x: number y: number @@ -15,16 +18,22 @@ interface Icon { interface IconCloudProps { icons?: React.ReactNode[] images?: string[] + showControl?: boolean } function easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3) } -export function IconCloud({ icons, images }: IconCloudProps) { +export function IconCloud({ + icons, + images, + showControl = false, +}: IconCloudProps) { const canvasRef = useRef(null) const [iconPositions, setIconPositions] = useState([]) const [isDragging, setIsDragging] = useState(false) + const [isPaused, setIsPaused] = useState(false) const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 }) const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) const [targetRotation, setTargetRotation] = useState<{ @@ -41,6 +50,21 @@ export function IconCloud({ icons, images }: IconCloudProps) { const iconCanvasesRef = useRef([]) const imagesLoadedRef = useRef([]) + // Pause animation if user prefers reduced motion + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)") + if (mediaQuery.matches) { + setIsPaused(true) + } + + const handleChange = (e: MediaQueryListEvent) => { + setIsPaused(e.matches) + } + + mediaQuery.addEventListener("change", handleChange) + return () => mediaQuery.removeEventListener("change", handleChange) + }, []) + // Create icon canvases once when icons/images change useEffect(() => { if (!icons && !images) return @@ -242,7 +266,7 @@ export function IconCloud({ icons, images }: IconCloudProps) { if (progress >= 1) { setTargetRotation(null) } - } else if (!isDragging) { + } else if (!isDragging && !isPaused) { rotationRef.current = { x: rotationRef.current.x + (dy / canvas.height) * speed, y: rotationRef.current.y + (dx / canvas.width) * speed, @@ -304,20 +328,41 @@ export function IconCloud({ icons, images }: IconCloudProps) { cancelAnimationFrame(animationFrameRef.current) } } - }, [icons, images, iconPositions, isDragging, mousePos, targetRotation]) + }, [ + icons, + images, + iconPositions, + isDragging, + isPaused, + mousePos, + targetRotation, + ]) return ( - +
+ + {showControl && ( + + )} +
) } From 36265ec1b3358f7a79c8936b888b42d3d619bc26 Mon Sep 17 00:00:00 2001 From: SamHacker Date: Tue, 21 Jul 2026 21:07:26 +0800 Subject: [PATCH 2/6] docs(icon-cloud): add showControl prop and demo with control button Add icon-cloud-demo-4 to showcase the control button functionality. Update icon-cloud.mdx with 'With Control Button' section and showControl prop docs. --- .../content/docs/components/icon-cloud.mdx | 13 +++-- apps/www/public/llms-full.txt | 52 +++++++++++++++++++ apps/www/public/llms.txt | 1 + apps/www/public/r/icon-cloud-demo-4.json | 17 ++++++ apps/www/public/r/registry.json | 15 ++++++ apps/www/public/registry.json | 15 ++++++ apps/www/registry.json | 15 ++++++ apps/www/registry/__index__.tsx | 17 ++++++ .../registry/example/icon-cloud-demo-4.tsx | 46 ++++++++++++++++ apps/www/registry/registry-examples.ts | 13 +++++ 10 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 apps/www/public/r/icon-cloud-demo-4.json create mode 100644 apps/www/registry/example/icon-cloud-demo-4.tsx diff --git a/apps/www/content/docs/components/icon-cloud.mdx b/apps/www/content/docs/components/icon-cloud.mdx index 362ab93f9..04d902e83 100644 --- a/apps/www/content/docs/components/icon-cloud.mdx +++ b/apps/www/content/docs/components/icon-cloud.mdx @@ -46,6 +46,10 @@ npx shadcn@latest add @magicui/icon-cloud +## With Control Button + + + ## Usage ```tsx showLineNumbers @@ -60,7 +64,8 @@ import { IconCloud } from "@/components/ui/icon-cloud" ## Props -| Prop | Type | Default | Description | -| -------- | ------------------- | ------- | ------------------------------------------ | -| `icons` | `React.ReactNode[]` | `[]` | Array of icons to render in the cloud | -| `images` | `string[]` | `[]` | Array of image URLs to render in the cloud | +| Prop | Type | Default | Description | +| ------------- | ------------------- | ------- | ------------------------------------------ | +| `icons` | `React.ReactNode[]` | `[]` | Array of icons to render in the cloud | +| `images` | `string[]` | `[]` | Array of image URLs to render in the cloud | +| `showControl` | `boolean` | `false` | Show play/pause control button | diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index efe27c33e..eed9c8cea 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -10923,6 +10923,58 @@ export default function IconCloudDemo() { } +===== EXAMPLE: icon-cloud-demo-4 ===== +Title: Icon Cloud Demo 4 + +--- file: example/icon-cloud-demo-4.tsx --- +import { IconCloud } from "@/registry/magicui/icon-cloud" + +const slugs = [ + "typescript", + "javascript", + "dart", + "java", + "react", + "flutter", + "android", + "html5", + "css3", + "nodedotjs", + "express", + "nextdotjs", + "prisma", + "amazonaws", + "postgresql", + "firebase", + "nginx", + "vercel", + "testinglibrary", + "jest", + "cypress", + "docker", + "git", + "jira", + "github", + "gitlab", + "visualstudiocode", + "androidstudio", + "sonarqube", + "figma", +] + +export default function IconCloudDemo() { + const images = slugs.map( + (slug) => `https://cdn.simpleicons.org/${slug}/${slug}` + ) + + return ( +
+ +
+ ) +} + + ===== COMPONENT: interactive-grid-pattern ===== Title: Interactive Grid Pattern diff --git a/apps/www/public/llms.txt b/apps/www/public/llms.txt index a375dac2d..f0a605d7f 100644 --- a/apps/www/public/llms.txt +++ b/apps/www/public/llms.txt @@ -192,6 +192,7 @@ This file provides LLM-friendly entry points to documentation and examples. - [Icon Cloud Demo](https://github.com/magicuidesign/magicui/blob/main/example/icon-cloud-demo.tsx): Example usage - [Icon Cloud Demo 2](https://github.com/magicuidesign/magicui/blob/main/example/icon-cloud-demo-2.tsx): Example usage - [Icon Cloud Demo 3](https://github.com/magicuidesign/magicui/blob/main/example/icon-cloud-demo-3.tsx): Example usage +- [Icon Cloud Demo 4](https://github.com/magicuidesign/magicui/blob/main/example/icon-cloud-demo-4.tsx): Example usage - [Text Animate Demo](https://github.com/magicuidesign/magicui/blob/main/example/text-animate-demo.tsx): Example usage - [Text Animate Demo 2](https://github.com/magicuidesign/magicui/blob/main/example/text-animate-demo-2.tsx): Example usage - [Text Animate Demo 3](https://github.com/magicuidesign/magicui/blob/main/example/text-animate-demo-3.tsx): Example usage diff --git a/apps/www/public/r/icon-cloud-demo-4.json b/apps/www/public/r/icon-cloud-demo-4.json new file mode 100644 index 000000000..d2518ce2a --- /dev/null +++ b/apps/www/public/r/icon-cloud-demo-4.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "icon-cloud-demo-4", + "type": "registry:example", + "title": "Icon Cloud Demo 4", + "description": "Fourth example showing an interactive 3D icon cloud.", + "registryDependencies": [ + "@magicui/icon-cloud" + ], + "files": [ + { + "path": "registry/example/icon-cloud-demo-4.tsx", + "content": "import { IconCloud } from \"@/registry/magicui/icon-cloud\"\n\nconst slugs = [\n \"typescript\",\n \"javascript\",\n \"dart\",\n \"java\",\n \"react\",\n \"flutter\",\n \"android\",\n \"html5\",\n \"css3\",\n \"nodedotjs\",\n \"express\",\n \"nextdotjs\",\n \"prisma\",\n \"amazonaws\",\n \"postgresql\",\n \"firebase\",\n \"nginx\",\n \"vercel\",\n \"testinglibrary\",\n \"jest\",\n \"cypress\",\n \"docker\",\n \"git\",\n \"jira\",\n \"github\",\n \"gitlab\",\n \"visualstudiocode\",\n \"androidstudio\",\n \"sonarqube\",\n \"figma\",\n]\n\nexport default function IconCloudDemo() {\n const images = slugs.map(\n (slug) => `https://cdn.simpleicons.org/${slug}/${slug}`\n )\n\n return (\n
\n \n
\n )\n}\n", + "type": "registry:example" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json index b624cc4c0..6e0c0b9bb 100644 --- a/apps/www/public/r/registry.json +++ b/apps/www/public/r/registry.json @@ -3011,6 +3011,21 @@ } ] }, + { + "name": "icon-cloud-demo-4", + "type": "registry:example", + "title": "Icon Cloud Demo 4", + "description": "Fourth example showing an interactive 3D icon cloud.", + "registryDependencies": [ + "@magicui/icon-cloud" + ], + "files": [ + { + "path": "registry/example/icon-cloud-demo-4.tsx", + "type": "registry:example" + } + ] + }, { "name": "text-animate-demo", "type": "registry:example", diff --git a/apps/www/public/registry.json b/apps/www/public/registry.json index b624cc4c0..6e0c0b9bb 100644 --- a/apps/www/public/registry.json +++ b/apps/www/public/registry.json @@ -3011,6 +3011,21 @@ } ] }, + { + "name": "icon-cloud-demo-4", + "type": "registry:example", + "title": "Icon Cloud Demo 4", + "description": "Fourth example showing an interactive 3D icon cloud.", + "registryDependencies": [ + "@magicui/icon-cloud" + ], + "files": [ + { + "path": "registry/example/icon-cloud-demo-4.tsx", + "type": "registry:example" + } + ] + }, { "name": "text-animate-demo", "type": "registry:example", diff --git a/apps/www/registry.json b/apps/www/registry.json index b624cc4c0..6e0c0b9bb 100644 --- a/apps/www/registry.json +++ b/apps/www/registry.json @@ -3011,6 +3011,21 @@ } ] }, + { + "name": "icon-cloud-demo-4", + "type": "registry:example", + "title": "Icon Cloud Demo 4", + "description": "Fourth example showing an interactive 3D icon cloud.", + "registryDependencies": [ + "@magicui/icon-cloud" + ], + "files": [ + { + "path": "registry/example/icon-cloud-demo-4.tsx", + "type": "registry:example" + } + ] + }, { "name": "text-animate-demo", "type": "registry:example", diff --git a/apps/www/registry/__index__.tsx b/apps/www/registry/__index__.tsx index 35aa0bc8a..c136b98f8 100644 --- a/apps/www/registry/__index__.tsx +++ b/apps/www/registry/__index__.tsx @@ -3126,6 +3126,23 @@ export const Index: Record = { }), meta: undefined, }, + "icon-cloud-demo-4": { + name: "icon-cloud-demo-4", + description: "Fourth example showing an interactive 3D icon cloud.", + type: "registry:example", + registryDependencies: ["@magicui/icon-cloud"], + files: [{ + path: "registry/example/icon-cloud-demo-4.tsx", + type: "registry:example", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/example/icon-cloud-demo-4.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, + }, "text-animate-demo": { name: "text-animate-demo", description: "Example showing various text animations.", diff --git a/apps/www/registry/example/icon-cloud-demo-4.tsx b/apps/www/registry/example/icon-cloud-demo-4.tsx new file mode 100644 index 000000000..e2a1283af --- /dev/null +++ b/apps/www/registry/example/icon-cloud-demo-4.tsx @@ -0,0 +1,46 @@ +import { IconCloud } from "@/registry/magicui/icon-cloud" + +const slugs = [ + "typescript", + "javascript", + "dart", + "java", + "react", + "flutter", + "android", + "html5", + "css3", + "nodedotjs", + "express", + "nextdotjs", + "prisma", + "amazonaws", + "postgresql", + "firebase", + "nginx", + "vercel", + "testinglibrary", + "jest", + "cypress", + "docker", + "git", + "jira", + "github", + "gitlab", + "visualstudiocode", + "androidstudio", + "sonarqube", + "figma", +] + +export default function IconCloudDemo() { + const images = slugs.map( + (slug) => `https://cdn.simpleicons.org/${slug}/${slug}` + ) + + return ( +
+ +
+ ) +} diff --git a/apps/www/registry/registry-examples.ts b/apps/www/registry/registry-examples.ts index eb8c8ff10..7c3e1b66d 100644 --- a/apps/www/registry/registry-examples.ts +++ b/apps/www/registry/registry-examples.ts @@ -1431,6 +1431,19 @@ export const examples: Registry["items"] = [ }, ], }, + { + name: "icon-cloud-demo-4", + type: "registry:example", + title: "Icon Cloud Demo 4", + description: "Fourth example showing an interactive 3D icon cloud.", + registryDependencies: ["@magicui/icon-cloud"], + files: [ + { + path: "example/icon-cloud-demo-4.tsx", + type: "registry:example", + }, + ], + }, { name: "text-animate-demo", type: "registry:example", From 7383ac5d8182210048445d4cd4486e63afc56de8 Mon Sep 17 00:00:00 2001 From: Jinho Yeom Date: Tue, 21 Jul 2026 23:19:45 +0900 Subject: [PATCH 3/6] fix(icon-cloud): declare button and lucide-react registry dependencies --- apps/www/public/r/icon-cloud.json | 7 ++++++- apps/www/public/r/registry.json | 7 ++++++- apps/www/public/registry.json | 7 ++++++- apps/www/registry.json | 7 ++++++- apps/www/registry/__index__.tsx | 2 +- apps/www/registry/registry-ui.ts | 3 ++- 6 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/www/public/r/icon-cloud.json b/apps/www/public/r/icon-cloud.json index 771e94476..28386cf6f 100644 --- a/apps/www/public/r/icon-cloud.json +++ b/apps/www/public/r/icon-cloud.json @@ -4,7 +4,12 @@ "type": "registry:ui", "title": "Icon Cloud", "description": "An interactive 3D tag cloud component", - "dependencies": [], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "button" + ], "files": [ { "path": "registry/magicui/icon-cloud.tsx", diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json index 6e0c0b9bb..3d4154b05 100644 --- a/apps/www/public/r/registry.json +++ b/apps/www/public/r/registry.json @@ -924,7 +924,12 @@ "type": "registry:ui", "title": "Icon Cloud", "description": "An interactive 3D tag cloud component", - "dependencies": [], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "button" + ], "files": [ { "path": "registry/magicui/icon-cloud.tsx", diff --git a/apps/www/public/registry.json b/apps/www/public/registry.json index 6e0c0b9bb..3d4154b05 100644 --- a/apps/www/public/registry.json +++ b/apps/www/public/registry.json @@ -924,7 +924,12 @@ "type": "registry:ui", "title": "Icon Cloud", "description": "An interactive 3D tag cloud component", - "dependencies": [], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "button" + ], "files": [ { "path": "registry/magicui/icon-cloud.tsx", diff --git a/apps/www/registry.json b/apps/www/registry.json index 6e0c0b9bb..3d4154b05 100644 --- a/apps/www/registry.json +++ b/apps/www/registry.json @@ -924,7 +924,12 @@ "type": "registry:ui", "title": "Icon Cloud", "description": "An interactive 3D tag cloud component", - "dependencies": [], + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "button" + ], "files": [ { "path": "registry/magicui/icon-cloud.tsx", diff --git a/apps/www/registry/__index__.tsx b/apps/www/registry/__index__.tsx index c136b98f8..3481877c7 100644 --- a/apps/www/registry/__index__.tsx +++ b/apps/www/registry/__index__.tsx @@ -886,7 +886,7 @@ export const Index: Record = { name: "icon-cloud", description: "An interactive 3D tag cloud component", type: "registry:ui", - registryDependencies: undefined, + registryDependencies: ["button"], files: [{ path: "registry/magicui/icon-cloud.tsx", type: "registry:ui", diff --git a/apps/www/registry/registry-ui.ts b/apps/www/registry/registry-ui.ts index 81f626874..d42e6a097 100644 --- a/apps/www/registry/registry-ui.ts +++ b/apps/www/registry/registry-ui.ts @@ -884,7 +884,8 @@ export const ui: Registry["items"] = [ type: "registry:ui", title: "Icon Cloud", description: "An interactive 3D tag cloud component", - dependencies: [], + dependencies: ["lucide-react"], + registryDependencies: ["button"], files: [ { path: "magicui/icon-cloud.tsx", From f98cffcf0dbef1afc2b57ec712d5c8597c63c984 Mon Sep 17 00:00:00 2001 From: Jinho Yeom Date: Tue, 21 Jul 2026 23:25:12 +0900 Subject: [PATCH 4/6] feat(icon-cloud): show the play/pause control by default --- apps/www/content/docs/components/icon-cloud.mdx | 8 +++++++- apps/www/public/llms-full.txt | 2 +- apps/www/public/r/icon-cloud.json | 2 +- apps/www/registry/magicui/icon-cloud.tsx | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/www/content/docs/components/icon-cloud.mdx b/apps/www/content/docs/components/icon-cloud.mdx index 04d902e83..d4ebcaf01 100644 --- a/apps/www/content/docs/components/icon-cloud.mdx +++ b/apps/www/content/docs/components/icon-cloud.mdx @@ -68,4 +68,10 @@ import { IconCloud } from "@/components/ui/icon-cloud" | ------------- | ------------------- | ------- | ------------------------------------------ | | `icons` | `React.ReactNode[]` | `[]` | Array of icons to render in the cloud | | `images` | `string[]` | `[]` | Array of image URLs to render in the cloud | -| `showControl` | `boolean` | `false` | Show play/pause control button | +| `showControl` | `boolean` | `true` | Show play/pause control button | + +## Accessibility + +The cloud rotates automatically, so it ships with a play/pause control by default to satisfy [WCAG 2.1 SC 2.2.2 (Pause, Stop, Hide)](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html). + +Rotation also pauses automatically when the user has `prefers-reduced-motion: reduce` enabled. Setting `showControl={false}` removes the only way to resume it, so opt out only when your layout provides its own control. diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index eed9c8cea..cbdebd0a4 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -10327,7 +10327,7 @@ function easeOutCubic(t: number): number { export function IconCloud({ icons, images, - showControl = false, + showControl = true, }: IconCloudProps) { const canvasRef = useRef(null) const [iconPositions, setIconPositions] = useState([]) diff --git a/apps/www/public/r/icon-cloud.json b/apps/www/public/r/icon-cloud.json index 28386cf6f..a55c78cc0 100644 --- a/apps/www/public/r/icon-cloud.json +++ b/apps/www/public/r/icon-cloud.json @@ -13,7 +13,7 @@ "files": [ { "path": "registry/magicui/icon-cloud.tsx", - "content": "\"use client\"\n\nimport React, { useEffect, useRef, useState } from \"react\"\nimport { Pause, Play } from \"lucide-react\"\nimport { renderToString } from \"react-dom/server\"\n\nimport { Button } from \"@/components/ui/button\"\n\ninterface Icon {\n x: number\n y: number\n z: number\n scale: number\n opacity: number\n id: number\n}\n\ninterface IconCloudProps {\n icons?: React.ReactNode[]\n images?: string[]\n showControl?: boolean\n}\n\nfunction easeOutCubic(t: number): number {\n return 1 - Math.pow(1 - t, 3)\n}\n\nexport function IconCloud({\n icons,\n images,\n showControl = false,\n}: IconCloudProps) {\n const canvasRef = useRef(null)\n const [iconPositions, setIconPositions] = useState([])\n const [isDragging, setIsDragging] = useState(false)\n const [isPaused, setIsPaused] = useState(false)\n const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 })\n const [mousePos, setMousePos] = useState({ x: 0, y: 0 })\n const [targetRotation, setTargetRotation] = useState<{\n x: number\n y: number\n startX: number\n startY: number\n distance: number\n startTime: number\n duration: number\n } | null>(null)\n const animationFrameRef = useRef(0)\n const rotationRef = useRef({ x: 0, y: 0 })\n const iconCanvasesRef = useRef([])\n const imagesLoadedRef = useRef([])\n\n // Pause animation if user prefers reduced motion\n useEffect(() => {\n const mediaQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n if (mediaQuery.matches) {\n setIsPaused(true)\n }\n\n const handleChange = (e: MediaQueryListEvent) => {\n setIsPaused(e.matches)\n }\n\n mediaQuery.addEventListener(\"change\", handleChange)\n return () => mediaQuery.removeEventListener(\"change\", handleChange)\n }, [])\n\n // Create icon canvases once when icons/images change\n useEffect(() => {\n if (!icons && !images) return\n\n const items = icons ?? images ?? []\n imagesLoadedRef.current = new Array(items.length).fill(false)\n\n const newIconCanvases = items.map((item, index) => {\n const offscreen = document.createElement(\"canvas\")\n offscreen.width = 40\n offscreen.height = 40\n const offCtx = offscreen.getContext(\"2d\")\n\n if (offCtx) {\n if (images) {\n // Handle image URLs directly\n const img = new Image()\n img.crossOrigin = \"anonymous\"\n img.src = items[index] as string\n img.onload = () => {\n offCtx.clearRect(0, 0, offscreen.width, offscreen.height)\n\n // Create circular clipping path\n offCtx.beginPath()\n offCtx.arc(20, 20, 20, 0, Math.PI * 2)\n offCtx.closePath()\n offCtx.clip()\n\n // Draw the image\n offCtx.drawImage(img, 0, 0, 40, 40)\n\n imagesLoadedRef.current[index] = true\n }\n } else {\n // Handle SVG icons\n offCtx.scale(0.4, 0.4)\n const svgString = renderToString(item as React.ReactElement)\n const img = new Image()\n img.src = \"data:image/svg+xml;base64,\" + btoa(svgString)\n img.onload = () => {\n offCtx.clearRect(0, 0, offscreen.width, offscreen.height)\n offCtx.drawImage(img, 0, 0)\n imagesLoadedRef.current[index] = true\n }\n }\n }\n return offscreen\n })\n\n iconCanvasesRef.current = newIconCanvases\n }, [icons, images])\n\n // Generate initial icon positions on a sphere\n useEffect(() => {\n const items = icons ?? images ?? []\n const newIcons: Icon[] = []\n const numIcons = items.length || 20\n\n // Fibonacci sphere parameters\n const offset = 2 / numIcons\n const increment = Math.PI * (3 - Math.sqrt(5))\n\n for (let i = 0; i < numIcons; i++) {\n const y = i * offset - 1 + offset / 2\n const r = Math.sqrt(1 - y * y)\n const phi = i * increment\n\n const x = Math.cos(phi) * r\n const z = Math.sin(phi) * r\n\n newIcons.push({\n x: x * 100,\n y: y * 100,\n z: z * 100,\n scale: 1,\n opacity: 1,\n id: i,\n })\n }\n setIconPositions(newIcons)\n }, [icons, images])\n\n // Handle mouse events\n const handleMouseDown = (e: React.MouseEvent) => {\n const rect = canvasRef.current?.getBoundingClientRect()\n if (!rect || !canvasRef.current) return\n\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n\n const ctx = canvasRef.current.getContext(\"2d\")\n if (!ctx) return\n\n iconPositions.forEach((icon) => {\n const cosX = Math.cos(rotationRef.current.x)\n const sinX = Math.sin(rotationRef.current.x)\n const cosY = Math.cos(rotationRef.current.y)\n const sinY = Math.sin(rotationRef.current.y)\n\n const rotatedX = icon.x * cosY - icon.z * sinY\n const rotatedZ = icon.x * sinY + icon.z * cosY\n const rotatedY = icon.y * cosX + rotatedZ * sinX\n\n const screenX = canvasRef.current!.width / 2 + rotatedX\n const screenY = canvasRef.current!.height / 2 + rotatedY\n\n const scale = (rotatedZ + 200) / 300\n const radius = 20 * scale\n const dx = x - screenX\n const dy = y - screenY\n\n if (dx * dx + dy * dy < radius * radius) {\n const targetX = -Math.atan2(\n icon.y,\n Math.sqrt(icon.x * icon.x + icon.z * icon.z)\n )\n const targetY = Math.atan2(icon.x, icon.z)\n\n const currentX = rotationRef.current.x\n const currentY = rotationRef.current.y\n const distance = Math.sqrt(\n Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)\n )\n\n const duration = Math.min(2000, Math.max(800, distance * 1000))\n\n setTargetRotation({\n x: targetX,\n y: targetY,\n startX: currentX,\n startY: currentY,\n distance,\n startTime: performance.now(),\n duration,\n })\n return\n }\n })\n\n setIsDragging(true)\n setLastMousePos({ x: e.clientX, y: e.clientY })\n }\n\n const handleMouseMove = (e: React.MouseEvent) => {\n const rect = canvasRef.current?.getBoundingClientRect()\n if (rect) {\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n setMousePos({ x, y })\n }\n\n if (isDragging) {\n const deltaX = e.clientX - lastMousePos.x\n const deltaY = e.clientY - lastMousePos.y\n\n rotationRef.current = {\n x: rotationRef.current.x + deltaY * 0.002,\n y: rotationRef.current.y + deltaX * 0.002,\n }\n\n setLastMousePos({ x: e.clientX, y: e.clientY })\n }\n }\n\n const handleMouseUp = () => {\n setIsDragging(false)\n }\n\n // Animation and rendering\n useEffect(() => {\n const canvas = canvasRef.current\n const ctx = canvas?.getContext(\"2d\")\n if (canvas && ctx) {\n const animate = () => {\n ctx.clearRect(0, 0, canvas.width, canvas.height)\n\n const centerX = canvas.width / 2\n const centerY = canvas.height / 2\n const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY)\n const dx = mousePos.x - centerX\n const dy = mousePos.y - centerY\n const distance = Math.sqrt(dx * dx + dy * dy)\n const speed = 0.003 + (distance / maxDistance) * 0.01\n\n if (targetRotation) {\n const elapsed = performance.now() - targetRotation.startTime\n const progress = Math.min(1, elapsed / targetRotation.duration)\n const easedProgress = easeOutCubic(progress)\n\n rotationRef.current = {\n x:\n targetRotation.startX +\n (targetRotation.x - targetRotation.startX) * easedProgress,\n y:\n targetRotation.startY +\n (targetRotation.y - targetRotation.startY) * easedProgress,\n }\n\n if (progress >= 1) {\n setTargetRotation(null)\n }\n } else if (!isDragging && !isPaused) {\n rotationRef.current = {\n x: rotationRef.current.x + (dy / canvas.height) * speed,\n y: rotationRef.current.y + (dx / canvas.width) * speed,\n }\n }\n\n iconPositions.forEach((icon, index) => {\n const cosX = Math.cos(rotationRef.current.x)\n const sinX = Math.sin(rotationRef.current.x)\n const cosY = Math.cos(rotationRef.current.y)\n const sinY = Math.sin(rotationRef.current.y)\n\n const rotatedX = icon.x * cosY - icon.z * sinY\n const rotatedZ = icon.x * sinY + icon.z * cosY\n const rotatedY = icon.y * cosX + rotatedZ * sinX\n\n const scale = (rotatedZ + 200) / 300\n const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200))\n\n ctx.save()\n ctx.translate(\n canvas.width / 2 + rotatedX,\n canvas.height / 2 + rotatedY\n )\n ctx.scale(scale, scale)\n ctx.globalAlpha = opacity\n\n if (icons || images) {\n // Only try to render icons/images if they exist\n if (\n iconCanvasesRef.current[index] &&\n imagesLoadedRef.current[index]\n ) {\n ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40)\n }\n } else {\n // Show numbered circles if no icons/images are provided\n ctx.beginPath()\n ctx.arc(0, 0, 20, 0, Math.PI * 2)\n ctx.fillStyle = \"#4444ff\"\n ctx.fill()\n ctx.fillStyle = \"white\"\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.font = \"16px Arial\"\n ctx.fillText(`${icon.id + 1}`, 0, 0)\n }\n\n ctx.restore()\n })\n animationFrameRef.current = requestAnimationFrame(animate)\n }\n\n animate()\n }\n\n return () => {\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current)\n }\n }\n }, [\n icons,\n images,\n iconPositions,\n isDragging,\n isPaused,\n mousePos,\n targetRotation,\n ])\n\n return (\n
\n \n {showControl && (\n setIsPaused(!isPaused)}\n aria-label={isPaused ? \"Play Animation\" : \"Pause Animation\"}\n className=\"absolute top-2 right-2\"\n >\n {isPaused ? : }\n \n )}\n
\n )\n}\n", + "content": "\"use client\"\n\nimport React, { useEffect, useRef, useState } from \"react\"\nimport { Pause, Play } from \"lucide-react\"\nimport { renderToString } from \"react-dom/server\"\n\nimport { Button } from \"@/components/ui/button\"\n\ninterface Icon {\n x: number\n y: number\n z: number\n scale: number\n opacity: number\n id: number\n}\n\ninterface IconCloudProps {\n icons?: React.ReactNode[]\n images?: string[]\n showControl?: boolean\n}\n\nfunction easeOutCubic(t: number): number {\n return 1 - Math.pow(1 - t, 3)\n}\n\nexport function IconCloud({\n icons,\n images,\n showControl = true,\n}: IconCloudProps) {\n const canvasRef = useRef(null)\n const [iconPositions, setIconPositions] = useState([])\n const [isDragging, setIsDragging] = useState(false)\n const [isPaused, setIsPaused] = useState(false)\n const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 })\n const [mousePos, setMousePos] = useState({ x: 0, y: 0 })\n const [targetRotation, setTargetRotation] = useState<{\n x: number\n y: number\n startX: number\n startY: number\n distance: number\n startTime: number\n duration: number\n } | null>(null)\n const animationFrameRef = useRef(0)\n const rotationRef = useRef({ x: 0, y: 0 })\n const iconCanvasesRef = useRef([])\n const imagesLoadedRef = useRef([])\n\n // Pause animation if user prefers reduced motion\n useEffect(() => {\n const mediaQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n if (mediaQuery.matches) {\n setIsPaused(true)\n }\n\n const handleChange = (e: MediaQueryListEvent) => {\n setIsPaused(e.matches)\n }\n\n mediaQuery.addEventListener(\"change\", handleChange)\n return () => mediaQuery.removeEventListener(\"change\", handleChange)\n }, [])\n\n // Create icon canvases once when icons/images change\n useEffect(() => {\n if (!icons && !images) return\n\n const items = icons ?? images ?? []\n imagesLoadedRef.current = new Array(items.length).fill(false)\n\n const newIconCanvases = items.map((item, index) => {\n const offscreen = document.createElement(\"canvas\")\n offscreen.width = 40\n offscreen.height = 40\n const offCtx = offscreen.getContext(\"2d\")\n\n if (offCtx) {\n if (images) {\n // Handle image URLs directly\n const img = new Image()\n img.crossOrigin = \"anonymous\"\n img.src = items[index] as string\n img.onload = () => {\n offCtx.clearRect(0, 0, offscreen.width, offscreen.height)\n\n // Create circular clipping path\n offCtx.beginPath()\n offCtx.arc(20, 20, 20, 0, Math.PI * 2)\n offCtx.closePath()\n offCtx.clip()\n\n // Draw the image\n offCtx.drawImage(img, 0, 0, 40, 40)\n\n imagesLoadedRef.current[index] = true\n }\n } else {\n // Handle SVG icons\n offCtx.scale(0.4, 0.4)\n const svgString = renderToString(item as React.ReactElement)\n const img = new Image()\n img.src = \"data:image/svg+xml;base64,\" + btoa(svgString)\n img.onload = () => {\n offCtx.clearRect(0, 0, offscreen.width, offscreen.height)\n offCtx.drawImage(img, 0, 0)\n imagesLoadedRef.current[index] = true\n }\n }\n }\n return offscreen\n })\n\n iconCanvasesRef.current = newIconCanvases\n }, [icons, images])\n\n // Generate initial icon positions on a sphere\n useEffect(() => {\n const items = icons ?? images ?? []\n const newIcons: Icon[] = []\n const numIcons = items.length || 20\n\n // Fibonacci sphere parameters\n const offset = 2 / numIcons\n const increment = Math.PI * (3 - Math.sqrt(5))\n\n for (let i = 0; i < numIcons; i++) {\n const y = i * offset - 1 + offset / 2\n const r = Math.sqrt(1 - y * y)\n const phi = i * increment\n\n const x = Math.cos(phi) * r\n const z = Math.sin(phi) * r\n\n newIcons.push({\n x: x * 100,\n y: y * 100,\n z: z * 100,\n scale: 1,\n opacity: 1,\n id: i,\n })\n }\n setIconPositions(newIcons)\n }, [icons, images])\n\n // Handle mouse events\n const handleMouseDown = (e: React.MouseEvent) => {\n const rect = canvasRef.current?.getBoundingClientRect()\n if (!rect || !canvasRef.current) return\n\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n\n const ctx = canvasRef.current.getContext(\"2d\")\n if (!ctx) return\n\n iconPositions.forEach((icon) => {\n const cosX = Math.cos(rotationRef.current.x)\n const sinX = Math.sin(rotationRef.current.x)\n const cosY = Math.cos(rotationRef.current.y)\n const sinY = Math.sin(rotationRef.current.y)\n\n const rotatedX = icon.x * cosY - icon.z * sinY\n const rotatedZ = icon.x * sinY + icon.z * cosY\n const rotatedY = icon.y * cosX + rotatedZ * sinX\n\n const screenX = canvasRef.current!.width / 2 + rotatedX\n const screenY = canvasRef.current!.height / 2 + rotatedY\n\n const scale = (rotatedZ + 200) / 300\n const radius = 20 * scale\n const dx = x - screenX\n const dy = y - screenY\n\n if (dx * dx + dy * dy < radius * radius) {\n const targetX = -Math.atan2(\n icon.y,\n Math.sqrt(icon.x * icon.x + icon.z * icon.z)\n )\n const targetY = Math.atan2(icon.x, icon.z)\n\n const currentX = rotationRef.current.x\n const currentY = rotationRef.current.y\n const distance = Math.sqrt(\n Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)\n )\n\n const duration = Math.min(2000, Math.max(800, distance * 1000))\n\n setTargetRotation({\n x: targetX,\n y: targetY,\n startX: currentX,\n startY: currentY,\n distance,\n startTime: performance.now(),\n duration,\n })\n return\n }\n })\n\n setIsDragging(true)\n setLastMousePos({ x: e.clientX, y: e.clientY })\n }\n\n const handleMouseMove = (e: React.MouseEvent) => {\n const rect = canvasRef.current?.getBoundingClientRect()\n if (rect) {\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n setMousePos({ x, y })\n }\n\n if (isDragging) {\n const deltaX = e.clientX - lastMousePos.x\n const deltaY = e.clientY - lastMousePos.y\n\n rotationRef.current = {\n x: rotationRef.current.x + deltaY * 0.002,\n y: rotationRef.current.y + deltaX * 0.002,\n }\n\n setLastMousePos({ x: e.clientX, y: e.clientY })\n }\n }\n\n const handleMouseUp = () => {\n setIsDragging(false)\n }\n\n // Animation and rendering\n useEffect(() => {\n const canvas = canvasRef.current\n const ctx = canvas?.getContext(\"2d\")\n if (canvas && ctx) {\n const animate = () => {\n ctx.clearRect(0, 0, canvas.width, canvas.height)\n\n const centerX = canvas.width / 2\n const centerY = canvas.height / 2\n const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY)\n const dx = mousePos.x - centerX\n const dy = mousePos.y - centerY\n const distance = Math.sqrt(dx * dx + dy * dy)\n const speed = 0.003 + (distance / maxDistance) * 0.01\n\n if (targetRotation) {\n const elapsed = performance.now() - targetRotation.startTime\n const progress = Math.min(1, elapsed / targetRotation.duration)\n const easedProgress = easeOutCubic(progress)\n\n rotationRef.current = {\n x:\n targetRotation.startX +\n (targetRotation.x - targetRotation.startX) * easedProgress,\n y:\n targetRotation.startY +\n (targetRotation.y - targetRotation.startY) * easedProgress,\n }\n\n if (progress >= 1) {\n setTargetRotation(null)\n }\n } else if (!isDragging && !isPaused) {\n rotationRef.current = {\n x: rotationRef.current.x + (dy / canvas.height) * speed,\n y: rotationRef.current.y + (dx / canvas.width) * speed,\n }\n }\n\n iconPositions.forEach((icon, index) => {\n const cosX = Math.cos(rotationRef.current.x)\n const sinX = Math.sin(rotationRef.current.x)\n const cosY = Math.cos(rotationRef.current.y)\n const sinY = Math.sin(rotationRef.current.y)\n\n const rotatedX = icon.x * cosY - icon.z * sinY\n const rotatedZ = icon.x * sinY + icon.z * cosY\n const rotatedY = icon.y * cosX + rotatedZ * sinX\n\n const scale = (rotatedZ + 200) / 300\n const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200))\n\n ctx.save()\n ctx.translate(\n canvas.width / 2 + rotatedX,\n canvas.height / 2 + rotatedY\n )\n ctx.scale(scale, scale)\n ctx.globalAlpha = opacity\n\n if (icons || images) {\n // Only try to render icons/images if they exist\n if (\n iconCanvasesRef.current[index] &&\n imagesLoadedRef.current[index]\n ) {\n ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40)\n }\n } else {\n // Show numbered circles if no icons/images are provided\n ctx.beginPath()\n ctx.arc(0, 0, 20, 0, Math.PI * 2)\n ctx.fillStyle = \"#4444ff\"\n ctx.fill()\n ctx.fillStyle = \"white\"\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.font = \"16px Arial\"\n ctx.fillText(`${icon.id + 1}`, 0, 0)\n }\n\n ctx.restore()\n })\n animationFrameRef.current = requestAnimationFrame(animate)\n }\n\n animate()\n }\n\n return () => {\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current)\n }\n }\n }, [\n icons,\n images,\n iconPositions,\n isDragging,\n isPaused,\n mousePos,\n targetRotation,\n ])\n\n return (\n
\n \n {showControl && (\n setIsPaused(!isPaused)}\n aria-label={isPaused ? \"Play Animation\" : \"Pause Animation\"}\n className=\"absolute top-2 right-2\"\n >\n {isPaused ? : }\n \n )}\n
\n )\n}\n", "type": "registry:ui" } ] diff --git a/apps/www/registry/magicui/icon-cloud.tsx b/apps/www/registry/magicui/icon-cloud.tsx index 8db352250..567bfb85d 100644 --- a/apps/www/registry/magicui/icon-cloud.tsx +++ b/apps/www/registry/magicui/icon-cloud.tsx @@ -28,7 +28,7 @@ function easeOutCubic(t: number): number { export function IconCloud({ icons, images, - showControl = false, + showControl = true, }: IconCloudProps) { const canvasRef = useRef(null) const [iconPositions, setIconPositions] = useState([]) From eba5c115d072b132e3d986b29f1dd8f366adc3c4 Mon Sep 17 00:00:00 2001 From: Jinho Yeom Date: Tue, 21 Jul 2026 23:27:19 +0900 Subject: [PATCH 5/6] docs(icon-cloud): drop the redundant control button example --- .../content/docs/components/icon-cloud.mdx | 4 -- apps/www/public/llms-full.txt | 52 ------------------- apps/www/public/llms.txt | 1 - apps/www/public/r/icon-cloud-demo-4.json | 17 ------ apps/www/public/r/registry.json | 15 ------ apps/www/public/registry.json | 15 ------ apps/www/registry.json | 15 ------ apps/www/registry/__index__.tsx | 17 ------ .../registry/example/icon-cloud-demo-4.tsx | 46 ---------------- apps/www/registry/registry-examples.ts | 13 ----- 10 files changed, 195 deletions(-) delete mode 100644 apps/www/public/r/icon-cloud-demo-4.json delete mode 100644 apps/www/registry/example/icon-cloud-demo-4.tsx diff --git a/apps/www/content/docs/components/icon-cloud.mdx b/apps/www/content/docs/components/icon-cloud.mdx index d4ebcaf01..09c4e527c 100644 --- a/apps/www/content/docs/components/icon-cloud.mdx +++ b/apps/www/content/docs/components/icon-cloud.mdx @@ -46,10 +46,6 @@ npx shadcn@latest add @magicui/icon-cloud -## With Control Button - - - ## Usage ```tsx showLineNumbers diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index cbdebd0a4..def6d1e1c 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -10923,58 +10923,6 @@ export default function IconCloudDemo() { } -===== EXAMPLE: icon-cloud-demo-4 ===== -Title: Icon Cloud Demo 4 - ---- file: example/icon-cloud-demo-4.tsx --- -import { IconCloud } from "@/registry/magicui/icon-cloud" - -const slugs = [ - "typescript", - "javascript", - "dart", - "java", - "react", - "flutter", - "android", - "html5", - "css3", - "nodedotjs", - "express", - "nextdotjs", - "prisma", - "amazonaws", - "postgresql", - "firebase", - "nginx", - "vercel", - "testinglibrary", - "jest", - "cypress", - "docker", - "git", - "jira", - "github", - "gitlab", - "visualstudiocode", - "androidstudio", - "sonarqube", - "figma", -] - -export default function IconCloudDemo() { - const images = slugs.map( - (slug) => `https://cdn.simpleicons.org/${slug}/${slug}` - ) - - return ( -
- -
- ) -} - - ===== COMPONENT: interactive-grid-pattern ===== Title: Interactive Grid Pattern diff --git a/apps/www/public/llms.txt b/apps/www/public/llms.txt index f0a605d7f..a375dac2d 100644 --- a/apps/www/public/llms.txt +++ b/apps/www/public/llms.txt @@ -192,7 +192,6 @@ This file provides LLM-friendly entry points to documentation and examples. - [Icon Cloud Demo](https://github.com/magicuidesign/magicui/blob/main/example/icon-cloud-demo.tsx): Example usage - [Icon Cloud Demo 2](https://github.com/magicuidesign/magicui/blob/main/example/icon-cloud-demo-2.tsx): Example usage - [Icon Cloud Demo 3](https://github.com/magicuidesign/magicui/blob/main/example/icon-cloud-demo-3.tsx): Example usage -- [Icon Cloud Demo 4](https://github.com/magicuidesign/magicui/blob/main/example/icon-cloud-demo-4.tsx): Example usage - [Text Animate Demo](https://github.com/magicuidesign/magicui/blob/main/example/text-animate-demo.tsx): Example usage - [Text Animate Demo 2](https://github.com/magicuidesign/magicui/blob/main/example/text-animate-demo-2.tsx): Example usage - [Text Animate Demo 3](https://github.com/magicuidesign/magicui/blob/main/example/text-animate-demo-3.tsx): Example usage diff --git a/apps/www/public/r/icon-cloud-demo-4.json b/apps/www/public/r/icon-cloud-demo-4.json deleted file mode 100644 index d2518ce2a..000000000 --- a/apps/www/public/r/icon-cloud-demo-4.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "icon-cloud-demo-4", - "type": "registry:example", - "title": "Icon Cloud Demo 4", - "description": "Fourth example showing an interactive 3D icon cloud.", - "registryDependencies": [ - "@magicui/icon-cloud" - ], - "files": [ - { - "path": "registry/example/icon-cloud-demo-4.tsx", - "content": "import { IconCloud } from \"@/registry/magicui/icon-cloud\"\n\nconst slugs = [\n \"typescript\",\n \"javascript\",\n \"dart\",\n \"java\",\n \"react\",\n \"flutter\",\n \"android\",\n \"html5\",\n \"css3\",\n \"nodedotjs\",\n \"express\",\n \"nextdotjs\",\n \"prisma\",\n \"amazonaws\",\n \"postgresql\",\n \"firebase\",\n \"nginx\",\n \"vercel\",\n \"testinglibrary\",\n \"jest\",\n \"cypress\",\n \"docker\",\n \"git\",\n \"jira\",\n \"github\",\n \"gitlab\",\n \"visualstudiocode\",\n \"androidstudio\",\n \"sonarqube\",\n \"figma\",\n]\n\nexport default function IconCloudDemo() {\n const images = slugs.map(\n (slug) => `https://cdn.simpleicons.org/${slug}/${slug}`\n )\n\n return (\n
\n \n
\n )\n}\n", - "type": "registry:example" - } - ] -} \ No newline at end of file diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json index 3d4154b05..4662fd09b 100644 --- a/apps/www/public/r/registry.json +++ b/apps/www/public/r/registry.json @@ -3016,21 +3016,6 @@ } ] }, - { - "name": "icon-cloud-demo-4", - "type": "registry:example", - "title": "Icon Cloud Demo 4", - "description": "Fourth example showing an interactive 3D icon cloud.", - "registryDependencies": [ - "@magicui/icon-cloud" - ], - "files": [ - { - "path": "registry/example/icon-cloud-demo-4.tsx", - "type": "registry:example" - } - ] - }, { "name": "text-animate-demo", "type": "registry:example", diff --git a/apps/www/public/registry.json b/apps/www/public/registry.json index 3d4154b05..4662fd09b 100644 --- a/apps/www/public/registry.json +++ b/apps/www/public/registry.json @@ -3016,21 +3016,6 @@ } ] }, - { - "name": "icon-cloud-demo-4", - "type": "registry:example", - "title": "Icon Cloud Demo 4", - "description": "Fourth example showing an interactive 3D icon cloud.", - "registryDependencies": [ - "@magicui/icon-cloud" - ], - "files": [ - { - "path": "registry/example/icon-cloud-demo-4.tsx", - "type": "registry:example" - } - ] - }, { "name": "text-animate-demo", "type": "registry:example", diff --git a/apps/www/registry.json b/apps/www/registry.json index 3d4154b05..4662fd09b 100644 --- a/apps/www/registry.json +++ b/apps/www/registry.json @@ -3016,21 +3016,6 @@ } ] }, - { - "name": "icon-cloud-demo-4", - "type": "registry:example", - "title": "Icon Cloud Demo 4", - "description": "Fourth example showing an interactive 3D icon cloud.", - "registryDependencies": [ - "@magicui/icon-cloud" - ], - "files": [ - { - "path": "registry/example/icon-cloud-demo-4.tsx", - "type": "registry:example" - } - ] - }, { "name": "text-animate-demo", "type": "registry:example", diff --git a/apps/www/registry/__index__.tsx b/apps/www/registry/__index__.tsx index 3481877c7..dfc823153 100644 --- a/apps/www/registry/__index__.tsx +++ b/apps/www/registry/__index__.tsx @@ -3126,23 +3126,6 @@ export const Index: Record = { }), meta: undefined, }, - "icon-cloud-demo-4": { - name: "icon-cloud-demo-4", - description: "Fourth example showing an interactive 3D icon cloud.", - type: "registry:example", - registryDependencies: ["@magicui/icon-cloud"], - files: [{ - path: "registry/example/icon-cloud-demo-4.tsx", - type: "registry:example", - target: "" - }], - component: React.lazy(async () => { - const mod = await import("@/registry/example/icon-cloud-demo-4.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, - }, "text-animate-demo": { name: "text-animate-demo", description: "Example showing various text animations.", diff --git a/apps/www/registry/example/icon-cloud-demo-4.tsx b/apps/www/registry/example/icon-cloud-demo-4.tsx deleted file mode 100644 index e2a1283af..000000000 --- a/apps/www/registry/example/icon-cloud-demo-4.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { IconCloud } from "@/registry/magicui/icon-cloud" - -const slugs = [ - "typescript", - "javascript", - "dart", - "java", - "react", - "flutter", - "android", - "html5", - "css3", - "nodedotjs", - "express", - "nextdotjs", - "prisma", - "amazonaws", - "postgresql", - "firebase", - "nginx", - "vercel", - "testinglibrary", - "jest", - "cypress", - "docker", - "git", - "jira", - "github", - "gitlab", - "visualstudiocode", - "androidstudio", - "sonarqube", - "figma", -] - -export default function IconCloudDemo() { - const images = slugs.map( - (slug) => `https://cdn.simpleicons.org/${slug}/${slug}` - ) - - return ( -
- -
- ) -} diff --git a/apps/www/registry/registry-examples.ts b/apps/www/registry/registry-examples.ts index 7c3e1b66d..eb8c8ff10 100644 --- a/apps/www/registry/registry-examples.ts +++ b/apps/www/registry/registry-examples.ts @@ -1431,19 +1431,6 @@ export const examples: Registry["items"] = [ }, ], }, - { - name: "icon-cloud-demo-4", - type: "registry:example", - title: "Icon Cloud Demo 4", - description: "Fourth example showing an interactive 3D icon cloud.", - registryDependencies: ["@magicui/icon-cloud"], - files: [ - { - path: "example/icon-cloud-demo-4.tsx", - type: "registry:example", - }, - ], - }, { name: "text-animate-demo", type: "registry:example", From d4277db1906294f7a089003098050ba1e148004f Mon Sep 17 00:00:00 2001 From: Jinho Yeom Date: Tue, 21 Jul 2026 23:35:53 +0900 Subject: [PATCH 6/6] perf(icon-cloud): stop the render loop while the animation is paused --- apps/www/public/llms-full.txt | 11 ++++++++++- apps/www/public/r/icon-cloud.json | 2 +- apps/www/registry/magicui/icon-cloud.tsx | 11 ++++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index def6d1e1c..e8a1ce9b1 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -10616,7 +10616,16 @@ export function IconCloud({ ctx.restore() }) - animationFrameRef.current = requestAnimationFrame(animate) + + const hasPendingAssets = + Boolean(icons || images) && + !imagesLoadedRef.current.every((loaded) => loaded) + const shouldContinue = + !isPaused || isDragging || targetRotation !== null || hasPendingAssets + + if (shouldContinue) { + animationFrameRef.current = requestAnimationFrame(animate) + } } animate() diff --git a/apps/www/public/r/icon-cloud.json b/apps/www/public/r/icon-cloud.json index a55c78cc0..11be00dfe 100644 --- a/apps/www/public/r/icon-cloud.json +++ b/apps/www/public/r/icon-cloud.json @@ -13,7 +13,7 @@ "files": [ { "path": "registry/magicui/icon-cloud.tsx", - "content": "\"use client\"\n\nimport React, { useEffect, useRef, useState } from \"react\"\nimport { Pause, Play } from \"lucide-react\"\nimport { renderToString } from \"react-dom/server\"\n\nimport { Button } from \"@/components/ui/button\"\n\ninterface Icon {\n x: number\n y: number\n z: number\n scale: number\n opacity: number\n id: number\n}\n\ninterface IconCloudProps {\n icons?: React.ReactNode[]\n images?: string[]\n showControl?: boolean\n}\n\nfunction easeOutCubic(t: number): number {\n return 1 - Math.pow(1 - t, 3)\n}\n\nexport function IconCloud({\n icons,\n images,\n showControl = true,\n}: IconCloudProps) {\n const canvasRef = useRef(null)\n const [iconPositions, setIconPositions] = useState([])\n const [isDragging, setIsDragging] = useState(false)\n const [isPaused, setIsPaused] = useState(false)\n const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 })\n const [mousePos, setMousePos] = useState({ x: 0, y: 0 })\n const [targetRotation, setTargetRotation] = useState<{\n x: number\n y: number\n startX: number\n startY: number\n distance: number\n startTime: number\n duration: number\n } | null>(null)\n const animationFrameRef = useRef(0)\n const rotationRef = useRef({ x: 0, y: 0 })\n const iconCanvasesRef = useRef([])\n const imagesLoadedRef = useRef([])\n\n // Pause animation if user prefers reduced motion\n useEffect(() => {\n const mediaQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n if (mediaQuery.matches) {\n setIsPaused(true)\n }\n\n const handleChange = (e: MediaQueryListEvent) => {\n setIsPaused(e.matches)\n }\n\n mediaQuery.addEventListener(\"change\", handleChange)\n return () => mediaQuery.removeEventListener(\"change\", handleChange)\n }, [])\n\n // Create icon canvases once when icons/images change\n useEffect(() => {\n if (!icons && !images) return\n\n const items = icons ?? images ?? []\n imagesLoadedRef.current = new Array(items.length).fill(false)\n\n const newIconCanvases = items.map((item, index) => {\n const offscreen = document.createElement(\"canvas\")\n offscreen.width = 40\n offscreen.height = 40\n const offCtx = offscreen.getContext(\"2d\")\n\n if (offCtx) {\n if (images) {\n // Handle image URLs directly\n const img = new Image()\n img.crossOrigin = \"anonymous\"\n img.src = items[index] as string\n img.onload = () => {\n offCtx.clearRect(0, 0, offscreen.width, offscreen.height)\n\n // Create circular clipping path\n offCtx.beginPath()\n offCtx.arc(20, 20, 20, 0, Math.PI * 2)\n offCtx.closePath()\n offCtx.clip()\n\n // Draw the image\n offCtx.drawImage(img, 0, 0, 40, 40)\n\n imagesLoadedRef.current[index] = true\n }\n } else {\n // Handle SVG icons\n offCtx.scale(0.4, 0.4)\n const svgString = renderToString(item as React.ReactElement)\n const img = new Image()\n img.src = \"data:image/svg+xml;base64,\" + btoa(svgString)\n img.onload = () => {\n offCtx.clearRect(0, 0, offscreen.width, offscreen.height)\n offCtx.drawImage(img, 0, 0)\n imagesLoadedRef.current[index] = true\n }\n }\n }\n return offscreen\n })\n\n iconCanvasesRef.current = newIconCanvases\n }, [icons, images])\n\n // Generate initial icon positions on a sphere\n useEffect(() => {\n const items = icons ?? images ?? []\n const newIcons: Icon[] = []\n const numIcons = items.length || 20\n\n // Fibonacci sphere parameters\n const offset = 2 / numIcons\n const increment = Math.PI * (3 - Math.sqrt(5))\n\n for (let i = 0; i < numIcons; i++) {\n const y = i * offset - 1 + offset / 2\n const r = Math.sqrt(1 - y * y)\n const phi = i * increment\n\n const x = Math.cos(phi) * r\n const z = Math.sin(phi) * r\n\n newIcons.push({\n x: x * 100,\n y: y * 100,\n z: z * 100,\n scale: 1,\n opacity: 1,\n id: i,\n })\n }\n setIconPositions(newIcons)\n }, [icons, images])\n\n // Handle mouse events\n const handleMouseDown = (e: React.MouseEvent) => {\n const rect = canvasRef.current?.getBoundingClientRect()\n if (!rect || !canvasRef.current) return\n\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n\n const ctx = canvasRef.current.getContext(\"2d\")\n if (!ctx) return\n\n iconPositions.forEach((icon) => {\n const cosX = Math.cos(rotationRef.current.x)\n const sinX = Math.sin(rotationRef.current.x)\n const cosY = Math.cos(rotationRef.current.y)\n const sinY = Math.sin(rotationRef.current.y)\n\n const rotatedX = icon.x * cosY - icon.z * sinY\n const rotatedZ = icon.x * sinY + icon.z * cosY\n const rotatedY = icon.y * cosX + rotatedZ * sinX\n\n const screenX = canvasRef.current!.width / 2 + rotatedX\n const screenY = canvasRef.current!.height / 2 + rotatedY\n\n const scale = (rotatedZ + 200) / 300\n const radius = 20 * scale\n const dx = x - screenX\n const dy = y - screenY\n\n if (dx * dx + dy * dy < radius * radius) {\n const targetX = -Math.atan2(\n icon.y,\n Math.sqrt(icon.x * icon.x + icon.z * icon.z)\n )\n const targetY = Math.atan2(icon.x, icon.z)\n\n const currentX = rotationRef.current.x\n const currentY = rotationRef.current.y\n const distance = Math.sqrt(\n Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)\n )\n\n const duration = Math.min(2000, Math.max(800, distance * 1000))\n\n setTargetRotation({\n x: targetX,\n y: targetY,\n startX: currentX,\n startY: currentY,\n distance,\n startTime: performance.now(),\n duration,\n })\n return\n }\n })\n\n setIsDragging(true)\n setLastMousePos({ x: e.clientX, y: e.clientY })\n }\n\n const handleMouseMove = (e: React.MouseEvent) => {\n const rect = canvasRef.current?.getBoundingClientRect()\n if (rect) {\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n setMousePos({ x, y })\n }\n\n if (isDragging) {\n const deltaX = e.clientX - lastMousePos.x\n const deltaY = e.clientY - lastMousePos.y\n\n rotationRef.current = {\n x: rotationRef.current.x + deltaY * 0.002,\n y: rotationRef.current.y + deltaX * 0.002,\n }\n\n setLastMousePos({ x: e.clientX, y: e.clientY })\n }\n }\n\n const handleMouseUp = () => {\n setIsDragging(false)\n }\n\n // Animation and rendering\n useEffect(() => {\n const canvas = canvasRef.current\n const ctx = canvas?.getContext(\"2d\")\n if (canvas && ctx) {\n const animate = () => {\n ctx.clearRect(0, 0, canvas.width, canvas.height)\n\n const centerX = canvas.width / 2\n const centerY = canvas.height / 2\n const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY)\n const dx = mousePos.x - centerX\n const dy = mousePos.y - centerY\n const distance = Math.sqrt(dx * dx + dy * dy)\n const speed = 0.003 + (distance / maxDistance) * 0.01\n\n if (targetRotation) {\n const elapsed = performance.now() - targetRotation.startTime\n const progress = Math.min(1, elapsed / targetRotation.duration)\n const easedProgress = easeOutCubic(progress)\n\n rotationRef.current = {\n x:\n targetRotation.startX +\n (targetRotation.x - targetRotation.startX) * easedProgress,\n y:\n targetRotation.startY +\n (targetRotation.y - targetRotation.startY) * easedProgress,\n }\n\n if (progress >= 1) {\n setTargetRotation(null)\n }\n } else if (!isDragging && !isPaused) {\n rotationRef.current = {\n x: rotationRef.current.x + (dy / canvas.height) * speed,\n y: rotationRef.current.y + (dx / canvas.width) * speed,\n }\n }\n\n iconPositions.forEach((icon, index) => {\n const cosX = Math.cos(rotationRef.current.x)\n const sinX = Math.sin(rotationRef.current.x)\n const cosY = Math.cos(rotationRef.current.y)\n const sinY = Math.sin(rotationRef.current.y)\n\n const rotatedX = icon.x * cosY - icon.z * sinY\n const rotatedZ = icon.x * sinY + icon.z * cosY\n const rotatedY = icon.y * cosX + rotatedZ * sinX\n\n const scale = (rotatedZ + 200) / 300\n const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200))\n\n ctx.save()\n ctx.translate(\n canvas.width / 2 + rotatedX,\n canvas.height / 2 + rotatedY\n )\n ctx.scale(scale, scale)\n ctx.globalAlpha = opacity\n\n if (icons || images) {\n // Only try to render icons/images if they exist\n if (\n iconCanvasesRef.current[index] &&\n imagesLoadedRef.current[index]\n ) {\n ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40)\n }\n } else {\n // Show numbered circles if no icons/images are provided\n ctx.beginPath()\n ctx.arc(0, 0, 20, 0, Math.PI * 2)\n ctx.fillStyle = \"#4444ff\"\n ctx.fill()\n ctx.fillStyle = \"white\"\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.font = \"16px Arial\"\n ctx.fillText(`${icon.id + 1}`, 0, 0)\n }\n\n ctx.restore()\n })\n animationFrameRef.current = requestAnimationFrame(animate)\n }\n\n animate()\n }\n\n return () => {\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current)\n }\n }\n }, [\n icons,\n images,\n iconPositions,\n isDragging,\n isPaused,\n mousePos,\n targetRotation,\n ])\n\n return (\n
\n \n {showControl && (\n setIsPaused(!isPaused)}\n aria-label={isPaused ? \"Play Animation\" : \"Pause Animation\"}\n className=\"absolute top-2 right-2\"\n >\n {isPaused ? : }\n \n )}\n
\n )\n}\n", + "content": "\"use client\"\n\nimport React, { useEffect, useRef, useState } from \"react\"\nimport { Pause, Play } from \"lucide-react\"\nimport { renderToString } from \"react-dom/server\"\n\nimport { Button } from \"@/components/ui/button\"\n\ninterface Icon {\n x: number\n y: number\n z: number\n scale: number\n opacity: number\n id: number\n}\n\ninterface IconCloudProps {\n icons?: React.ReactNode[]\n images?: string[]\n showControl?: boolean\n}\n\nfunction easeOutCubic(t: number): number {\n return 1 - Math.pow(1 - t, 3)\n}\n\nexport function IconCloud({\n icons,\n images,\n showControl = true,\n}: IconCloudProps) {\n const canvasRef = useRef(null)\n const [iconPositions, setIconPositions] = useState([])\n const [isDragging, setIsDragging] = useState(false)\n const [isPaused, setIsPaused] = useState(false)\n const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 })\n const [mousePos, setMousePos] = useState({ x: 0, y: 0 })\n const [targetRotation, setTargetRotation] = useState<{\n x: number\n y: number\n startX: number\n startY: number\n distance: number\n startTime: number\n duration: number\n } | null>(null)\n const animationFrameRef = useRef(0)\n const rotationRef = useRef({ x: 0, y: 0 })\n const iconCanvasesRef = useRef([])\n const imagesLoadedRef = useRef([])\n\n // Pause animation if user prefers reduced motion\n useEffect(() => {\n const mediaQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n if (mediaQuery.matches) {\n setIsPaused(true)\n }\n\n const handleChange = (e: MediaQueryListEvent) => {\n setIsPaused(e.matches)\n }\n\n mediaQuery.addEventListener(\"change\", handleChange)\n return () => mediaQuery.removeEventListener(\"change\", handleChange)\n }, [])\n\n // Create icon canvases once when icons/images change\n useEffect(() => {\n if (!icons && !images) return\n\n const items = icons ?? images ?? []\n imagesLoadedRef.current = new Array(items.length).fill(false)\n\n const newIconCanvases = items.map((item, index) => {\n const offscreen = document.createElement(\"canvas\")\n offscreen.width = 40\n offscreen.height = 40\n const offCtx = offscreen.getContext(\"2d\")\n\n if (offCtx) {\n if (images) {\n // Handle image URLs directly\n const img = new Image()\n img.crossOrigin = \"anonymous\"\n img.src = items[index] as string\n img.onload = () => {\n offCtx.clearRect(0, 0, offscreen.width, offscreen.height)\n\n // Create circular clipping path\n offCtx.beginPath()\n offCtx.arc(20, 20, 20, 0, Math.PI * 2)\n offCtx.closePath()\n offCtx.clip()\n\n // Draw the image\n offCtx.drawImage(img, 0, 0, 40, 40)\n\n imagesLoadedRef.current[index] = true\n }\n } else {\n // Handle SVG icons\n offCtx.scale(0.4, 0.4)\n const svgString = renderToString(item as React.ReactElement)\n const img = new Image()\n img.src = \"data:image/svg+xml;base64,\" + btoa(svgString)\n img.onload = () => {\n offCtx.clearRect(0, 0, offscreen.width, offscreen.height)\n offCtx.drawImage(img, 0, 0)\n imagesLoadedRef.current[index] = true\n }\n }\n }\n return offscreen\n })\n\n iconCanvasesRef.current = newIconCanvases\n }, [icons, images])\n\n // Generate initial icon positions on a sphere\n useEffect(() => {\n const items = icons ?? images ?? []\n const newIcons: Icon[] = []\n const numIcons = items.length || 20\n\n // Fibonacci sphere parameters\n const offset = 2 / numIcons\n const increment = Math.PI * (3 - Math.sqrt(5))\n\n for (let i = 0; i < numIcons; i++) {\n const y = i * offset - 1 + offset / 2\n const r = Math.sqrt(1 - y * y)\n const phi = i * increment\n\n const x = Math.cos(phi) * r\n const z = Math.sin(phi) * r\n\n newIcons.push({\n x: x * 100,\n y: y * 100,\n z: z * 100,\n scale: 1,\n opacity: 1,\n id: i,\n })\n }\n setIconPositions(newIcons)\n }, [icons, images])\n\n // Handle mouse events\n const handleMouseDown = (e: React.MouseEvent) => {\n const rect = canvasRef.current?.getBoundingClientRect()\n if (!rect || !canvasRef.current) return\n\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n\n const ctx = canvasRef.current.getContext(\"2d\")\n if (!ctx) return\n\n iconPositions.forEach((icon) => {\n const cosX = Math.cos(rotationRef.current.x)\n const sinX = Math.sin(rotationRef.current.x)\n const cosY = Math.cos(rotationRef.current.y)\n const sinY = Math.sin(rotationRef.current.y)\n\n const rotatedX = icon.x * cosY - icon.z * sinY\n const rotatedZ = icon.x * sinY + icon.z * cosY\n const rotatedY = icon.y * cosX + rotatedZ * sinX\n\n const screenX = canvasRef.current!.width / 2 + rotatedX\n const screenY = canvasRef.current!.height / 2 + rotatedY\n\n const scale = (rotatedZ + 200) / 300\n const radius = 20 * scale\n const dx = x - screenX\n const dy = y - screenY\n\n if (dx * dx + dy * dy < radius * radius) {\n const targetX = -Math.atan2(\n icon.y,\n Math.sqrt(icon.x * icon.x + icon.z * icon.z)\n )\n const targetY = Math.atan2(icon.x, icon.z)\n\n const currentX = rotationRef.current.x\n const currentY = rotationRef.current.y\n const distance = Math.sqrt(\n Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)\n )\n\n const duration = Math.min(2000, Math.max(800, distance * 1000))\n\n setTargetRotation({\n x: targetX,\n y: targetY,\n startX: currentX,\n startY: currentY,\n distance,\n startTime: performance.now(),\n duration,\n })\n return\n }\n })\n\n setIsDragging(true)\n setLastMousePos({ x: e.clientX, y: e.clientY })\n }\n\n const handleMouseMove = (e: React.MouseEvent) => {\n const rect = canvasRef.current?.getBoundingClientRect()\n if (rect) {\n const x = e.clientX - rect.left\n const y = e.clientY - rect.top\n setMousePos({ x, y })\n }\n\n if (isDragging) {\n const deltaX = e.clientX - lastMousePos.x\n const deltaY = e.clientY - lastMousePos.y\n\n rotationRef.current = {\n x: rotationRef.current.x + deltaY * 0.002,\n y: rotationRef.current.y + deltaX * 0.002,\n }\n\n setLastMousePos({ x: e.clientX, y: e.clientY })\n }\n }\n\n const handleMouseUp = () => {\n setIsDragging(false)\n }\n\n // Animation and rendering\n useEffect(() => {\n const canvas = canvasRef.current\n const ctx = canvas?.getContext(\"2d\")\n if (canvas && ctx) {\n const animate = () => {\n ctx.clearRect(0, 0, canvas.width, canvas.height)\n\n const centerX = canvas.width / 2\n const centerY = canvas.height / 2\n const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY)\n const dx = mousePos.x - centerX\n const dy = mousePos.y - centerY\n const distance = Math.sqrt(dx * dx + dy * dy)\n const speed = 0.003 + (distance / maxDistance) * 0.01\n\n if (targetRotation) {\n const elapsed = performance.now() - targetRotation.startTime\n const progress = Math.min(1, elapsed / targetRotation.duration)\n const easedProgress = easeOutCubic(progress)\n\n rotationRef.current = {\n x:\n targetRotation.startX +\n (targetRotation.x - targetRotation.startX) * easedProgress,\n y:\n targetRotation.startY +\n (targetRotation.y - targetRotation.startY) * easedProgress,\n }\n\n if (progress >= 1) {\n setTargetRotation(null)\n }\n } else if (!isDragging && !isPaused) {\n rotationRef.current = {\n x: rotationRef.current.x + (dy / canvas.height) * speed,\n y: rotationRef.current.y + (dx / canvas.width) * speed,\n }\n }\n\n iconPositions.forEach((icon, index) => {\n const cosX = Math.cos(rotationRef.current.x)\n const sinX = Math.sin(rotationRef.current.x)\n const cosY = Math.cos(rotationRef.current.y)\n const sinY = Math.sin(rotationRef.current.y)\n\n const rotatedX = icon.x * cosY - icon.z * sinY\n const rotatedZ = icon.x * sinY + icon.z * cosY\n const rotatedY = icon.y * cosX + rotatedZ * sinX\n\n const scale = (rotatedZ + 200) / 300\n const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200))\n\n ctx.save()\n ctx.translate(\n canvas.width / 2 + rotatedX,\n canvas.height / 2 + rotatedY\n )\n ctx.scale(scale, scale)\n ctx.globalAlpha = opacity\n\n if (icons || images) {\n // Only try to render icons/images if they exist\n if (\n iconCanvasesRef.current[index] &&\n imagesLoadedRef.current[index]\n ) {\n ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40)\n }\n } else {\n // Show numbered circles if no icons/images are provided\n ctx.beginPath()\n ctx.arc(0, 0, 20, 0, Math.PI * 2)\n ctx.fillStyle = \"#4444ff\"\n ctx.fill()\n ctx.fillStyle = \"white\"\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.font = \"16px Arial\"\n ctx.fillText(`${icon.id + 1}`, 0, 0)\n }\n\n ctx.restore()\n })\n\n const hasPendingAssets =\n Boolean(icons || images) &&\n !imagesLoadedRef.current.every((loaded) => loaded)\n const shouldContinue =\n !isPaused || isDragging || targetRotation !== null || hasPendingAssets\n\n if (shouldContinue) {\n animationFrameRef.current = requestAnimationFrame(animate)\n }\n }\n\n animate()\n }\n\n return () => {\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current)\n }\n }\n }, [\n icons,\n images,\n iconPositions,\n isDragging,\n isPaused,\n mousePos,\n targetRotation,\n ])\n\n return (\n
\n \n {showControl && (\n setIsPaused(!isPaused)}\n aria-label={isPaused ? \"Play Animation\" : \"Pause Animation\"}\n className=\"absolute top-2 right-2\"\n >\n {isPaused ? : }\n \n )}\n
\n )\n}\n", "type": "registry:ui" } ] diff --git a/apps/www/registry/magicui/icon-cloud.tsx b/apps/www/registry/magicui/icon-cloud.tsx index 567bfb85d..a9d547ff0 100644 --- a/apps/www/registry/magicui/icon-cloud.tsx +++ b/apps/www/registry/magicui/icon-cloud.tsx @@ -317,7 +317,16 @@ export function IconCloud({ ctx.restore() }) - animationFrameRef.current = requestAnimationFrame(animate) + + const hasPendingAssets = + Boolean(icons || images) && + !imagesLoadedRef.current.every((loaded) => loaded) + const shouldContinue = + !isPaused || isDragging || targetRotation !== null || hasPendingAssets + + if (shouldContinue) { + animationFrameRef.current = requestAnimationFrame(animate) + } } animate()