Skip to content

Icon Cloud

Icons on a 3D sphere that auto-rotates; drag to spin.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/icon-cloud.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

  3. Copy the source

    components/ui/icon-cloud/icon-cloud.tsx
    "use client";
    
    import { useEffect, useRef, type ComponentProps, type ReactNode } from "react";
    import { cn } from "@/lib/utils";
    
    export interface CloudIcon {
      /** Name read by screen readers. */
      name: string;
      icon: ReactNode;
    }
    
    export interface IconCloudProps extends Omit<ComponentProps<"div">, "children"> {
      /** Icons placed evenly over a sphere. */
      icons: CloudIcon[];
      /** Auto-rotation in radians per second. @default 0.35 */
      speed?: number;
      /** Drag to spin, with a little inertia. @default true */
      draggable?: boolean;
      /** Icon tile size in pixels. @default 40 */
      iconSize?: number;
      /** Accessible label for the cloud. @default "Technologies" */
      label?: string;
    }
    
    function spherePoints(count: number) {
      const golden = Math.PI * (3 - Math.sqrt(5));
      return Array.from({ length: count }, (_, i) => {
        // Half-step offset keeps icons off the exact poles, where rotation would leave them still.
        const y = 1 - ((i + 0.5) / count) * 2;
        const r = Math.sqrt(1 - y * y);
        return [Math.cos(golden * i) * r, y, Math.sin(golden * i) * r] as [number, number, number];
      });
    }
    
    function place(point: [number, number, number], yaw: number, pitch: number, radius: number) {
      const [x, y, z] = point;
      const x1 = x * Math.cos(yaw) + z * Math.sin(yaw);
      const z1 = -x * Math.sin(yaw) + z * Math.cos(yaw);
      const y2 = y * Math.cos(pitch) - z1 * Math.sin(pitch);
      const z2 = y * Math.sin(pitch) + z1 * Math.cos(pitch);
      const depth = (z2 + 1) / 2; // 0 back … 1 front
      return {
        transform: `translate(-50%, -50%) translate3d(${(x1 * radius).toFixed(1)}px, ${(-y2 * radius).toFixed(1)}px, 0) scale(${(0.55 + depth * 0.55).toFixed(3)})`,
        opacity: (0.25 + depth * 0.75).toFixed(3),
        zIndex: String(Math.round(depth * 100)),
      };
    }
    
    export function IconCloud({ icons, speed = 0.35, draggable = true, iconSize = 40, label = "Technologies", className, ...props }: IconCloudProps) {
      const root = useRef<HTMLDivElement>(null);
      const items = useRef<(HTMLSpanElement | null)[]>([]);
      const points = spherePoints(icons.length);
      // Server render and first paint use the resting pose, at a fixed radius until measured.
      const initialRadius = 120;
    
      useEffect(() => {
        const el = root.current;
        if (!el) return;
        const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
        const pts = spherePoints(items.current.length);
        let radius = el.clientWidth * 0.38;
        let yaw = 0;
        let pitch = -0.25;
        let vx = speed;
        let vy = 0;
        let dragging = false;
        let lastX = 0;
        let lastY = 0;
        let raf = 0;
        let last = 0;
        let visible = true;
    
        const apply = () => {
          items.current.forEach((node, i) => {
            if (!node) return;
            const pose = place(pts[i], yaw, pitch, radius);
            node.style.transform = pose.transform;
            node.style.opacity = pose.opacity;
            node.style.zIndex = pose.zIndex;
          });
        };
        const frame = (now: number) => {
          raf = 0;
          const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
          last = now;
          if (!dragging) {
            // Ease any fling back toward the steady auto-rotation.
            vx += (speed - vx) * Math.min(1, dt * 1.5);
            vy *= 1 - Math.min(1, dt * 2.5);
            yaw += vx * dt;
            pitch = Math.max(-1.2, Math.min(1.2, pitch + vy * dt));
          }
          apply();
          if (visible && !reduce.matches && document.visibilityState === "visible") raf = requestAnimationFrame(frame);
          else last = 0;
        };
        const run = () => {
          if (!raf) raf = requestAnimationFrame(frame);
        };
    
        apply();
        run();
        const resize = new ResizeObserver(() => {
          radius = el.clientWidth * 0.38;
          apply();
        });
        resize.observe(el);
        const intersection = new IntersectionObserver(([entry]) => {
          visible = entry.isIntersecting;
          if (visible) run();
        });
        intersection.observe(el);
        const onVisibility = () => document.visibilityState === "visible" && run();
        document.addEventListener("visibilitychange", onVisibility);
    
        const onDown = (event: PointerEvent) => {
          if (!draggable || event.button !== 0) return;
          dragging = true;
          lastX = event.clientX;
          lastY = event.clientY;
        };
        const onMove = (event: PointerEvent) => {
          if (!dragging) return;
          if (!el.hasPointerCapture(event.pointerId)) el.setPointerCapture(event.pointerId);
          const dx = event.clientX - lastX;
          const dy = event.clientY - lastY;
          lastX = event.clientX;
          lastY = event.clientY;
          yaw += dx * 0.01;
          pitch = Math.max(-1.2, Math.min(1.2, pitch + dy * 0.01));
          vx = dx * 0.6;
          vy = dy * 0.6;
          apply();
          run();
        };
        const onUp = () => {
          dragging = false;
        };
        el.addEventListener("pointerdown", onDown);
        el.addEventListener("pointermove", onMove);
        el.addEventListener("pointerup", onUp);
        el.addEventListener("pointercancel", onUp);
        return () => {
          cancelAnimationFrame(raf);
          resize.disconnect();
          intersection.disconnect();
          document.removeEventListener("visibilitychange", onVisibility);
          el.removeEventListener("pointerdown", onDown);
          el.removeEventListener("pointermove", onMove);
          el.removeEventListener("pointerup", onUp);
          el.removeEventListener("pointercancel", onUp);
        };
      }, [speed, draggable, icons.length]);
    
      return (
        <div
          ref={root}
          role="group"
          aria-label={label}
          className={cn("relative aspect-square w-full max-w-sm min-w-64 touch-none select-none", draggable && "cursor-grab active:cursor-grabbing", className)}
          {...props}
        >
          <ul className="sr-only">
            {icons.map((item) => (
              <li key={item.name}>{item.name}</li>
            ))}
          </ul>
          {icons.map((item, i) => (
            <span
              key={item.name}
              ref={(node) => {
                items.current[i] = node;
              }}
              aria-hidden="true"
              title={item.name}
              className="absolute top-1/2 left-1/2 grid place-items-center rounded-xl border border-border bg-background text-foreground shadow-sm will-change-transform [&>svg]:size-[55%]"
              style={{ width: iconSize, height: iconSize, ...place(points[i], 0, -0.25, initialRadius) }}
            >
              {item.icon}
            </span>
          ))}
        </div>
      );
    }

Usage

import { IconCloud } from "@/components/ui/icon-cloud/icon-cloud";

<IconCloud icons={[{ name: "React", icon: <ReactIcon /> }, { name: "Figma", icon: <FigmaIcon /> }]} />

API reference

PropTypeDefaultDescription
icons*CloudIcon[]–Icons placed evenly over a sphere.
speednumber0.35Auto-rotation in radians per second.
draggablebooleantrueDrag to spin, with a little inertia.
iconSizenumber40Icon tile size in pixels.
labelstring"Technologies"Accessible label for the cloud.

Also accepts every native <div> attribute, including ref and className.

  • Icons sit on a Fibonacci sphere. Each frame writes transforms straight to the elements, so React never re-renders while it spins. Depth drives scale, opacity and stacking.
  • The server and the first paint use the same resting pose, so hydration matches. It pauses offscreen and in hidden tabs.

Accessibility

  • The cloud is a labelled group with a visually hidden list of every icon's name; the moving tiles are aria-hidden.
  • Dragging is optional, and nothing depends on the sphere's orientation.
  • With reduced motion the sphere holds its resting pose (dragging still works).