iconCloud
A Core block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call iconCloud() with no arguments for a working demo, or edit the code below live.
Implementation notes
Full 2D-canvas sphere-of-icons implementation as the spec calls for: golden-angle Fibonacci sphere point distribution (no pole clumping), idle auto-rotation, direct drag-to-spin, inertial coasting with a friction decay factor after release, painter's-algorithm back-to-front draw order, and depth-driven size/opacity interpolation. Respects prefers-reduced-motion for the idle spin (drag remains available, since that's user-initiated, not imposed motion). One deliberate API-shape simplification: vector icon content is accepted as inline SVG markup strings (glyphMarkup) rather than as full Domphy element trees, since a Domphy element is bound to one DOM node and can't be safely re-mounted per-frame onto a canvas — both image URLs and glyphMarkup are pre-rendered once to offscreen Image bitmaps, matching the spec's 'two alternate content sources, pre-rendered to offscreen images' guidance. In jsdom (no canvas npm package installed), getContext('2d') resolves to null, so the component's own guard bails out of the animation loop before it starts — this is the same, established fail-closed pattern this package already uses for its other canvas/WebGL components (see particles.ts, globe.ts), verified structurally rather than pixel-rendered in tests.
Status: ported · Reference: Magic UI original
// magicui "Icon Cloud" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). An
// interactive, auto-rotating "tag cloud" sphere of icon chips that the user
// can grab and spin with the mouse or touch, coasting with inertia on
// release.
//
// Rendered on a single 2D `<canvas>` (not WebGL/DOM 3D transforms) since the
// point count is small and cheap to project by hand: a fixed set of points is
// pre-distributed evenly over a unit sphere via a golden-angle (Fibonacci)
// spiral — this avoids the pole-clumping a naive latitude/longitude grid
// would produce. Every frame, the points are rotated by the current
// accumulated yaw/pitch (idle auto-rotation, or driven directly by drag
// delta while dragging; inertia decays the angular velocity by a friction
// factor each frame after release), projected orthographically to 2D, sorted
// back-to-front by depth (painter's algorithm) so nearer icons occlude
// farther ones, and drawn with `drawImage` at a size/opacity interpolated
// from normalized depth. Vector glyphs and bitmap image URLs are both
// pre-rendered to offscreen `Image` objects (via a data: URI for glyphs) so
// the per-frame draw path is uniform regardless of content source.
import type { DomphyElement, ElementNode } from "@domphy/core";
import { themeColorToken } from "@domphy/theme";
export interface IconCloudItem {
/** Bitmap image URL. Takes priority over `glyphMarkup` when both are set. */
image?: string;
/** Inline `<svg>...</svg>` markup (a plain string, not a Domphy element tree — pre-rendered to
* an offscreen bitmap once, the same as `image`). Ignored when `image` is set. */
glyphMarkup?: string;
/** Accessible label, surfaced via the canvas's `title` attribute on hover and in the textual fallback. */
label?: string;
}
export interface IconCloudProps {
/** Icons distributed evenly over the sphere. Defaults to 20 generic hand-authored glyphs. */
icons?: IconCloudItem[];
/** Square canvas/container size, in px. Defaults to 380. */
size?: number;
/** Idle auto-rotation angular speed, radians/frame. Defaults to 0.003. */
autoRotateSpeed?: number;
/** Drag sensitivity — radians of rotation per px of pointer movement. Defaults to 0.006. */
dragSensitivity?: number;
/** Inertia decay factor applied to angular velocity each frame after release (0–1; closer to 1 coasts longer). Defaults to 0.95. */
friction?: number;
/** Icon render size range in px, `[nearest, farthest]`. Defaults to `[42, 14]`. */
iconScaleRange?: [number, number];
/** Icon opacity range, `[nearest, farthest]`. Defaults to `[1, 0.25]`. */
iconOpacityRange?: [number, number];
ariaLabel?: string;
}
const DEFAULT_SIZE = 380;
const DEFAULT_AUTO_ROTATE_SPEED = 0.003;
const DEFAULT_DRAG_SENSITIVITY = 0.006;
const DEFAULT_FRICTION = 0.95;
const DEFAULT_ICON_SCALE_RANGE: [number, number] = [42, 14];
const DEFAULT_ICON_OPACITY_RANGE: [number, number] = [1, 0.25];
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const VELOCITY_REST_EPSILON = 0.00005;
// Hand-authored, simple geometric glyph shapes (24x24, stroke=currentColor) —
// generic placeholders standing in for "an icon goes here", not tracing any
// real icon set or trademarked logo.
const DEFAULT_GLYPH_INNER_SHAPES: string[] = [
'<circle cx="12" cy="12" r="8"/>',
'<rect x="5" y="5" width="14" height="14" rx="2"/>',
'<polygon points="12,4 20,20 4,20"/>',
'<polygon points="12,3 21,12 12,21 3,12"/>',
'<polygon points="12,3 20,8 20,16 12,21 4,16 4,8"/>',
'<polygon points="12,3 14.6,9.2 21.4,9.4 16,13.6 17.9,20.2 12,16.4 6.1,20.2 8,13.6 2.6,9.4 9.4,9.2"/>',
'<path d="M12 4v16M4 12h16"/>',
'<path d="M6 6l12 12M18 6L6 18"/>',
'<path d="M12 20s-7-4.4-9.5-9C.8 7.3 3 4 6.5 4c2 0 3.4 1 5.5 3 2.1-2 3.5-3 5.5-3 3.5 0 5.7 3.3 4 7-2.5 4.6-9.5 9-9.5 9z"/>',
'<path d="M7 18a4 4 0 0 1-.6-7.96A5 5 0 0 1 16 8a4 4 0 0 1 1 7.9M7 18h10"/>',
'<path d="M13 3 4 14h6l-1 7 9-11h-6l1-7z"/>',
'<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>',
'<circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M4 12H1M23 12h-3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>',
'<path d="M12 3s7 7.5 7 12a7 7 0 1 1-14 0c0-4.5 7-12 7-12z"/>',
'<path d="M20 4S5 5 5 15a7 7 0 0 0 14 0c0-4-2-8.5 1-11z"/>',
'<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 6.5l2.3 1.6M17.5 15.9l2.3 1.6M2 12h3M19 12h3M4.2 17.5l2.3-1.6M17.5 8.1l2.3-1.6"/>',
'<path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6l8-3z"/>',
'<path d="M6 3v18M6 4h12l-3 4 3 4H6"/>',
'<path d="M6 10a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6z"/><path d="M9.5 20a2.5 2.5 0 0 0 5 0"/>',
'<path d="M3 8l9-5 9 5-9 5-9-5z"/><path d="M3 8v8l9 5 9-5V8"/><path d="M12 13v8"/>',
];
function buildGlyphMarkup(innerShape: string, colorHex: string): string {
return (
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${colorHex}" ` +
`stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${innerShape}</svg>`
);
}
function defaultIcons(colorHex: string): IconCloudItem[] {
return DEFAULT_GLYPH_INNER_SHAPES.map((shape) => ({ glyphMarkup: buildGlyphMarkup(shape, colorHex) }));
}
interface SpherePoint {
x: number;
y: number;
z: number;
}
/** Golden-angle (Fibonacci) spiral — distributes `count` points evenly over a unit sphere
* surface with no pole clumping, cheaper than iterative relaxation for a small fixed set. */
function buildFibonacciSpherePoints(count: number): SpherePoint[] {
const points: SpherePoint[] = [];
for (let index = 0; index < count; index += 1) {
const y = count === 1 ? 0 : 1 - (index / (count - 1)) * 2;
const radiusAtY = Math.sqrt(Math.max(0, 1 - y * y));
const theta = GOLDEN_ANGLE * index;
points.push({ x: Math.cos(theta) * radiusAtY, y, z: Math.sin(theta) * radiusAtY });
}
return points;
}
function rotatePoint(point: SpherePoint, yaw: number, pitch: number): SpherePoint {
const cosYaw = Math.cos(yaw);
const sinYaw = Math.sin(yaw);
const x1 = point.x * cosYaw + point.z * sinYaw;
const z1 = -point.x * sinYaw + point.z * cosYaw;
const cosPitch = Math.cos(pitch);
const sinPitch = Math.sin(pitch);
const y2 = point.y * cosPitch - z1 * sinPitch;
const z2 = point.y * sinPitch + z1 * cosPitch;
return { x: x1, y: y2, z: z2 };
}
function lerp(from: number, to: number, t: number): number {
return from + (to - from) * t;
}
/**
* An interactive, auto-rotating sphere of icon chips (2D canvas), drag-to-spin
* with inertial coasting. Call with no arguments for a working demo — 20
* generic glyphs auto-rotating, themed from the current context.
*/
function iconCloud(props: IconCloudProps = {}): DomphyElement<"div"> {
const size = props.size ?? DEFAULT_SIZE;
const autoRotateSpeed = props.autoRotateSpeed ?? DEFAULT_AUTO_ROTATE_SPEED;
const dragSensitivity = props.dragSensitivity ?? DEFAULT_DRAG_SENSITIVITY;
const friction = props.friction ?? DEFAULT_FRICTION;
const [nearSize, farSize] = props.iconScaleRange ?? DEFAULT_ICON_SCALE_RANGE;
const [nearOpacity, farOpacity] = props.iconOpacityRange ?? DEFAULT_ICON_OPACITY_RANGE;
return {
div: [],
role: "img",
ariaLabel: props.ariaLabel ?? "Interactive rotating cloud of icons — drag to spin",
style: {
position: "relative",
width: `${size}px`,
height: `${size}px`,
maxWidth: "100%",
aspectRatio: "1 / 1",
marginInline: "auto",
touchAction: "none",
},
_onMount: (node: ElementNode) => {
const container = node.domElement as HTMLElement | null;
if (!container || typeof document === "undefined") return;
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.style.position = "absolute";
canvas.style.inset = "0";
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.cursor = "grab";
container.appendChild(canvas);
const context = canvas.getContext("2d");
if (!context) return;
let iconColor = "#5a6472";
try {
iconColor = themeColorToken(node, "shift-11", "neutral");
} catch {
// Fall back to the default gray above if the theme isn't resolvable yet.
}
const icons = props.icons ?? defaultIcons(iconColor);
const points = buildFibonacciSpherePoints(icons.length);
interface LoadedIcon {
image: HTMLImageElement | null;
}
const loaded: LoadedIcon[] = icons.map(() => ({ image: null }));
icons.forEach((icon, index) => {
const source =
icon.image ??
(icon.glyphMarkup ? `data:image/svg+xml,${encodeURIComponent(icon.glyphMarkup)}` : undefined);
if (!source) return;
const image = new Image();
image.decoding = "async";
image.onload = () => {
loaded[index].image = image;
};
image.src = source;
});
const prefersReducedMotion =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let yaw = 0;
let pitch = 0.15;
let velocityYaw = 0;
let velocityPitch = 0;
let dragging = false;
let lastPointerX = 0;
let lastPointerY = 0;
let width = container.clientWidth || size;
let frameHandle: number | null = null;
let resizeObserver: ResizeObserver | null = null;
const resizeCanvas = () => {
width = container.clientWidth || size;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = width * dpr;
canvas.height = width * dpr;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resizeCanvas();
const draw = () => {
const sphereRadius = width * 0.42;
const centerX = width / 2;
const centerY = width / 2;
context.clearRect(0, 0, width, width);
const projected = points.map((point, index) => {
const rotated = rotatePoint(point, yaw, pitch);
const depth = (rotated.z + 1) / 2; // 0 = back, 1 = front
return {
index,
x: centerX + rotated.x * sphereRadius,
y: centerY + rotated.y * sphereRadius,
depth,
};
});
projected.sort((a, b) => a.depth - b.depth);
for (const entry of projected) {
const image = loaded[entry.index].image;
if (!image || !image.complete || image.naturalWidth === 0) continue;
const iconSize = lerp(farSize, nearSize, entry.depth);
const opacity = lerp(farOpacity, nearOpacity, entry.depth);
context.globalAlpha = opacity;
context.drawImage(image, entry.x - iconSize / 2, entry.y - iconSize / 2, iconSize, iconSize);
}
context.globalAlpha = 1;
};
const tick = () => {
// Belt-and-suspenders: bail without rescheduling once the canvas is
// no longer in the document, so this loop can't outlive the
// component even if the framework's own "Remove" hook never fires
// (e.g. a host that wipes the DOM directly instead of going through
// node removal).
if (!canvas.isConnected) return;
if (!dragging) {
if (Math.abs(velocityYaw) > VELOCITY_REST_EPSILON || Math.abs(velocityPitch) > VELOCITY_REST_EPSILON) {
yaw += velocityYaw;
pitch += velocityPitch;
velocityYaw *= friction;
velocityPitch *= friction;
} else if (!prefersReducedMotion) {
yaw += autoRotateSpeed;
velocityYaw = 0;
velocityPitch = 0;
}
}
draw();
frameHandle = requestAnimationFrame(tick);
};
const handlePointerDown = (event: PointerEvent) => {
dragging = true;
lastPointerX = event.clientX;
lastPointerY = event.clientY;
velocityYaw = 0;
velocityPitch = 0;
canvas.style.cursor = "grabbing";
try {
canvas.setPointerCapture(event.pointerId);
} catch {
// Pointer capture is best-effort — unsupported/detached targets are fine to ignore.
}
};
const handlePointerMove = (event: PointerEvent) => {
if (!dragging) return;
const deltaX = event.clientX - lastPointerX;
const deltaY = event.clientY - lastPointerY;
lastPointerX = event.clientX;
lastPointerY = event.clientY;
const deltaYaw = deltaX * dragSensitivity;
const deltaPitch = deltaY * dragSensitivity;
yaw += deltaYaw;
pitch += deltaPitch;
velocityYaw = deltaYaw;
velocityPitch = deltaPitch;
};
const handlePointerUp = (event: PointerEvent) => {
if (!dragging) return;
dragging = false;
canvas.style.cursor = "grab";
try {
canvas.releasePointerCapture(event.pointerId);
} catch {
// Best-effort release, as above.
}
};
canvas.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => resizeCanvas());
resizeObserver.observe(container);
}
frameHandle = requestAnimationFrame(tick);
node.addHook("Remove", () => {
if (frameHandle !== null) cancelAnimationFrame(frameHandle);
resizeObserver?.disconnect();
canvas.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
});
},
};
}
export { iconCloud };