Rotating Icon Cloud
Rotating Icon Cloud is a React and Tailwind CSS media component from Magic UI, licensed MIT. Copy it and paste it into your project.
Magic UI
MIT
media
What it is
Rotating Icon Cloud is a React and Tailwind CSS media component from Magic UI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i lucide-react
Licence
This component comes from Magic UI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Rotating Icon Cloud
"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
z: number
scale: number
opacity: number
id: number
}
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,
showControl = true,
}: IconCloudProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [iconPositions, setIconPositions] = useState<Icon[]>([])
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<{
x: number
y: number
startX: number
startY: number
distance: number
startTime: number
duration: number
} | null>(null)
const animationFrameRef = useRef<number>(0)
const rotationRef = useRef({ x: 0, y: 0 })
const iconCanvasesRef = useRef<HTMLCanvasElement[]>([])
const imagesLoadedRef = useRef<boolean[]>([])
// 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
const items = icons ?? images ?? []
imagesLoadedRef.current = new Array(items.length).fill(false)
const newIconCanvases = items.map((item, index) => {
const offscreen = document.createElement("canvas")
offscreen.width = 40
offscreen.height = 40
const offCtx = offscreen.getContext("2d")
if (offCtx) {
if (images) {
// Handle image URLs directly
const img = new Image()
img.crossOrigin = "anonymous"
img.src = items[index] as string
img.onload = () => {
offCtx.clearRect(0, 0, offscreen.width, offscreen.height)
// Create circular clipping path
offCtx.beginPath()
offCtx.arc(20, 20, 20, 0, Math.PI * 2)
offCtx.closePath()
offCtx.clip()
// Draw the image
offCtx.drawImage(img, 0, 0, 40, 40)
imagesLoadedRef.current[index] = true
}
} else {
// Handle SVG icons
offCtx.scale(0.4, 0.4)
const svgString = renderToString(item as React.ReactElement)
const img = new Image()
img.src = "data:image/svg+xml;base64," + btoa(svgString)
img.onload = () => {
offCtx.clearRect(0, 0, offscreen.width, offscreen.height)
offCtx.drawImage(img, 0, 0)
imagesLoadedRef.current[index] = true
}
}
}
return offscreen
})
iconCanvasesRef.current = newIconCanvases
}, [icons, images])
// Generate initial icon positions on a sphere
useEffect(() => {
const items = icons ?? images ?? []
const newIcons: Icon[] = []
const numIcons = items.length || 20
// Fibonacci sphere parameters
const offset = 2 / numIcons
const increment = Math.PI * (3 - Math.sqrt(5))
for (let i = 0; i < numIcons; i++) {
const y = i * offset - 1 + offset / 2
const r = Math.sqrt(1 - y * y)
const phi = i * increment
const x = Math.cos(phi) * r
const z = Math.sin(phi) * r
newIcons.push({
x: x * 100,
y: y * 100,
z: z * 100,
scale: 1,
opacity: 1,
id: i,
})
}
setIconPositions(newIcons)
}, [icons, images])
// Handle mouse events
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current?.getBoundingClientRect()
if (!rect || !canvasRef.current) return
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const ctx = canvasRef.current.getContext("2d")
if (!ctx) return
iconPositions.forEach((icon) => {
const cosX = Math.cos(rotationRef.current.x)
const sinX = Math.sin(rotationRef.current.x)
const cosY = Math.cos(rotationRef.current.y)
const sinY = Math.sin(rotationRef.current.y)
const rotatedX = icon.x * cosY - icon.z * sinY
const rotatedZ = icon.x * sinY + icon.z * cosY
const rotatedY = icon.y * cosX + rotatedZ * sinX
const screenX = canvasRef.current!.width / 2 + rotatedX
const screenY = canvasRef.current!.height / 2 + rotatedY
const scale = (rotatedZ + 200) / 300
const radius = 20 * scale
const dx = x - screenX
const dy = y - screenY
if (dx * dx + dy * dy < radius * radius) {
const targetX = -Math.atan2(
icon.y,
Math.sqrt(icon.x * icon.x + icon.z * icon.z)
)
const targetY = Math.atan2(icon.x, icon.z)
const currentX = rotationRef.current.x
const currentY = rotationRef.current.y
const distance = Math.sqrt(
Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)
)
const duration = Math.min(2000, Math.max(800, distance * 1000))
setTargetRotation({
x: targetX,
y: targetY,
startX: currentX,
startY: currentY,
distance,
startTime: performance.now(),
duration,
})
return
}
})
setIsDragging(true)
setLastMousePos({ x: e.clientX, y: e.clientY })
}
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current?.getBoundingClientRect()
if (rect) {
const x = e.clientX - rect.left
const y = e.clientY - rect.top
setMousePos({ x, y })
}
if (isDragging) {
const deltaX = e.clientX - lastMousePos.x
const deltaY = e.clientY - lastMousePos.y
rotationRef.current = {
x: rotationRef.current.x + deltaY * 0.002,
y: rotationRef.current.y + deltaX * 0.002,
}
setLastMousePos({ x: e.clientX, y: e.clientY })
}
}
const handleMouseUp = () => {
setIsDragging(false)
}
// Animation and rendering
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext("2d")
if (canvas && ctx) {
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
const centerX = canvas.width / 2
const centerY = canvas.height / 2
const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY)
const dx = mousePos.x - centerX
const dy = mousePos.y - centerY
const distance = Math.sqrt(dx * dx + dy * dy)
const speed = 0.003 + (distance / maxDistance) * 0.01
if (targetRotation) {
const elapsed = performance.now() - targetRotation.startTime
const progress = Math.min(1, elapsed / targetRotation.duration)
const easedProgress = easeOutCubic(progress)
rotationRef.current = {
x:
targetRotation.startX +
(targetRotation.x - targetRotation.startX) * easedProgress,
y:
targetRotation.startY +
(targetRotation.y - targetRotation.startY) * easedProgress,
}
if (progress >= 1) {
setTargetRotation(null)
}
} else if (!isDragging && !isPaused) {
rotationRef.current = {
x: rotationRef.current.x + (dy / canvas.height) * speed,
y: rotationRef.current.y + (dx / canvas.width) * speed,
}
}
iconPositions.forEach((icon, index) => {
const cosX = Math.cos(rotationRef.current.x)
const sinX = Math.sin(rotationRef.current.x)
const cosY = Math.cos(rotationRef.current.y)
const sinY = Math.sin(rotationRef.current.y)
const rotatedX = icon.x * cosY - icon.z * sinY
const rotatedZ = icon.x * sinY + icon.z * cosY
const rotatedY = icon.y * cosX + rotatedZ * sinX
const scale = (rotatedZ + 200) / 300
const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200))
ctx.save()
ctx.translate(
canvas.width / 2 + rotatedX,
canvas.height / 2 + rotatedY
)
ctx.scale(scale, scale)
ctx.globalAlpha = opacity
if (icons || images) {
// Only try to render icons/images if they exist
if (
iconCanvasesRef.current[index] &&
imagesLoadedRef.current[index]
) {
ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40)
}
} else {
// Show numbered circles if no icons/images are provided
ctx.beginPath()
ctx.arc(0, 0, 20, 0, Math.PI * 2)
ctx.fillStyle = "#4444ff"
ctx.fill()
ctx.fillStyle = "white"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.font = "16px Arial"
ctx.fillText(`${icon.id + 1}`, 0, 0)
}
ctx.restore()
})
const hasPendingAssets =
Boolean(icons || images) &&
!imagesLoadedRef.current.every((loaded) => loaded)
const shouldContinue =
!isPaused || isDragging || targetRotation !== null || hasPendingAssets
if (shouldContinue) {
animationFrameRef.current = requestAnimationFrame(animate)
}
}
animate()
}
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [
icons,
images,
iconPositions,
isDragging,
isPaused,
mousePos,
targetRotation,
])
return (
<div className="relative inline-block">
<canvas
ref={canvasRef}
width={400}
height={400}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className="rounded-lg"
aria-label="Interactive 3D Icon Cloud"
role="img"
/>
{showControl && (
<Button
variant="outline"
size="icon"
onClick={() => setIsPaused(!isPaused)}
aria-label={isPaused ? "Play Animation" : "Pause Animation"}
className="absolute top-2 right-2"
>
{isPaused ? <Play size={16} /> : <Pause size={16} />}
</Button>
)}
</div>
)
}