diff --git a/apps/www/content/docs/components/icon-cloud.mdx b/apps/www/content/docs/components/icon-cloud.mdx index 362ab93f9..09c4e527c 100644 --- a/apps/www/content/docs/components/icon-cloud.mdx +++ b/apps/www/content/docs/components/icon-cloud.mdx @@ -60,7 +60,14 @@ 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` | `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 6d6b7de04..e8a1ce9b1 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 = true, +}: 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, @@ -10592,7 +10616,16 @@ export function IconCloud({ icons, images }: IconCloudProps) { 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() @@ -10603,21 +10636,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..11be00dfe 100644 --- a/apps/www/public/r/icon-cloud.json +++ b/apps/www/public/r/icon-cloud.json @@ -4,11 +4,16 @@ "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", - "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 = 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/public/r/registry.json b/apps/www/public/r/registry.json index b624cc4c0..4662fd09b 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 b624cc4c0..4662fd09b 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 b624cc4c0..4662fd09b 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 35aa0bc8a..dfc823153 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/magicui/icon-cloud.tsx b/apps/www/registry/magicui/icon-cloud.tsx index 54fc16a60..a9d547ff0 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 = true, +}: 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, @@ -293,7 +317,16 @@ export function IconCloud({ icons, images }: IconCloudProps) { 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() @@ -304,20 +337,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 && ( + + )} +
) } 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",