Image Sphere
3D rotating image gallery sphere for React. Drag to rotate, click to expand. Built with CSS 3D transforms and spring physics. Perfect for portfolios.
Last updated on
Basic Usage
import SphereImageGrid from "@/components/ui/image-sphere";
const sampleImages = [
{
id: "1",
src: "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=150&h=150&fit=crop",
alt: "User avatar 1",
title: "Alex Johnson",
description: "Senior Software Engineer",
},
{
id: "2",
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop",
alt: "User avatar 2",
title: "Sarah Miller",
description: "Product Designer",
},
{
id: "3",
src: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop",
alt: "User avatar 3",
title: "Michael Chen",
description: "Data Scientist",
},
{
id: "4",
src: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop",
alt: "User avatar 4",
title: "Emma Wilson",
description: "UX Researcher",
},
{
id: "5",
src: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop",
alt: "User avatar 5",
title: "David Brown",
description: "Frontend Developer",
},
{
id: "6",
src: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=150&h=150&fit=crop",
alt: "User avatar 6",
title: "Lisa Anderson",
description: "Marketing Lead",
},
{
id: "7",
src: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=150&h=150&fit=crop",
alt: "User avatar 7",
title: "James Taylor",
description: "DevOps Engineer",
},
{
id: "8",
src: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop",
alt: "User avatar 8",
title: "Olivia Davis",
description: "Project Manager",
},
];
export function ImageSphereDemo() {
return (
<div className="flex items-center justify-center p-8">
<SphereImageGrid
images={sampleImages}
containerSize={400}
sphereRadius={180}
/>
</div>
);
}Auto Rotate
Enable automatic rotation for a dynamic, hands-free display.
import SphereImageGrid from "@/components/ui/image-sphere";
const sampleImages = [
{
id: "1",
src: "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=150&h=150&fit=crop",
alt: "User avatar 1",
title: "Alex Johnson",
description: "Senior Software Engineer",
},
{
id: "2",
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop",
alt: "User avatar 2",
title: "Sarah Miller",
description: "Product Designer",
},
{
id: "3",
src: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop",
alt: "User avatar 3",
title: "Michael Chen",
description: "Data Scientist",
},
{
id: "4",
src: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop",
alt: "User avatar 4",
title: "Emma Wilson",
description: "UX Researcher",
},
{
id: "5",
src: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop",
alt: "User avatar 5",
title: "David Brown",
description: "Frontend Developer",
},
{
id: "6",
src: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=150&h=150&fit=crop",
alt: "User avatar 6",
title: "Lisa Anderson",
description: "Marketing Lead",
},
{
id: "7",
src: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=150&h=150&fit=crop",
alt: "User avatar 7",
title: "James Taylor",
description: "DevOps Engineer",
},
{
id: "8",
src: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop",
alt: "User avatar 8",
title: "Olivia Davis",
description: "Project Manager",
},
{
id: "9",
src: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=150&h=150&fit=crop",
alt: "User avatar 9",
title: "Robert Martin",
description: "Backend Developer",
},
{
id: "10",
src: "https://images.unsplash.com/photo-1517841905240-472988babdf9?w=150&h=150&fit=crop",
alt: "User avatar 10",
title: "Jennifer Lee",
description: "QA Engineer",
},
];
export function ImageSphereAutorotateDemo() {
return (
<div className="flex items-center justify-center p-8">
<SphereImageGrid
images={sampleImages}
containerSize={400}
sphereRadius={180}
autoRotate={true}
autoRotateSpeed={0.3}
/>
</div>
);
}Large Gallery
Display more images with adjusted sizing for better visibility.
import SphereImageGrid from "@/components/ui/image-sphere";
const sampleImages = Array.from({ length: 20 }, (_, i) => ({
id: `${i + 1}`,
src: `https://picsum.photos/seed/${i + 1}/150/150`,
alt: `Image ${i + 1}`,
title: `Image ${i + 1}`,
description: `This is image number ${i + 1} in the gallery`,
}));
export function ImageSphereLargeDemo() {
return (
<div className="flex items-center justify-center p-8">
<SphereImageGrid
images={sampleImages}
containerSize={500}
sphereRadius={220}
baseImageScale={0.1}
autoRotate={true}
autoRotateSpeed={0.2}
/>
</div>
);
}Custom Configuration
Customize drag sensitivity, momentum, and visual properties.
import SphereImageGrid from "@/components/ui/image-sphere";
const techLogos = [
{
id: "1",
src: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/react/react-original.svg",
alt: "React",
title: "React",
description: "A JavaScript library for building user interfaces",
},
{
id: "2",
src: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/typescript/typescript-original.svg",
alt: "TypeScript",
title: "TypeScript",
description: "JavaScript with syntax for types",
},
{
id: "3",
src: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/nextjs/nextjs-original.svg",
alt: "Next.js",
title: "Next.js",
description: "The React Framework for the Web",
},
{
id: "4",
src: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/nodejs/nodejs-original.svg",
alt: "Node.js",
title: "Node.js",
description: "JavaScript runtime built on V8",
},
{
id: "5",
src: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/python/python-original.svg",
alt: "Python",
title: "Python",
description: "A programming language that lets you work quickly",
},
{
id: "6",
src: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/docker/docker-original.svg",
alt: "Docker",
title: "Docker",
description: "Develop, Ship, and Run Anywhere",
},
{
id: "7",
src: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/kubernetes/kubernetes-plain.svg",
alt: "Kubernetes",
title: "Kubernetes",
description: "Production-Grade Container Orchestration",
},
{
id: "8",
src: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/postgresql/postgresql-original.svg",
alt: "PostgreSQL",
title: "PostgreSQL",
description: "The World's Most Advanced Open Source Database",
},
];
export function ImageSphereCustomDemo() {
return (
<div className="flex items-center justify-center p-8">
<SphereImageGrid
images={techLogos}
containerSize={350}
sphereRadius={150}
dragSensitivity={0.7}
momentumDecay={0.92}
baseImageScale={0.15}
hoverScale={1.3}
autoRotate={true}
autoRotateSpeed={0.5}
/>
</div>
);
}Installation
CLI
npx shadcn@latest add "https://jolyui.dev/r/image-sphere"Manual
Install the following dependencies:
npm install lucide-reactCopy and paste the following code into your project. component/ui/image-sphere.tsx
import { X } from "lucide-react";
import Image from "next/image";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
export interface Position3D {
x: number;
y: number;
z: number;
}
export interface SphericalPosition {
theta: number; // Azimuth angle in degrees
phi: number; // Polar angle in degrees
radius: number; // Distance from center
}
export interface WorldPosition extends Position3D {
scale: number;
zIndex: number;
isVisible: boolean;
fadeOpacity: number;
originalIndex: number;
}
export interface ImageData {
id: string;
src: string;
alt: string;
title?: string;
description?: string;
}
export interface SphereImageGridProps {
images?: ImageData[];
containerSize?: number;
sphereRadius?: number;
dragSensitivity?: number;
momentumDecay?: number;
maxRotationSpeed?: number;
baseImageScale?: number;
hoverScale?: number;
perspective?: number;
autoRotate?: boolean;
autoRotateSpeed?: number;
className?: string;
}
interface RotationState {
x: number;
y: number;
z: number;
}
interface VelocityState {
x: number;
y: number;
}
interface MousePosition {
x: number;
y: number;
}
// ==========================================
// CONSTANTS & CONFIGURATION
// ==========================================
const SPHERE_MATH = {
degreesToRadians: (degrees: number): number => degrees * (Math.PI / 180),
radiansToDegrees: (radians: number): number => radians * (180 / Math.PI),
sphericalToCartesian: (
radius: number,
theta: number,
phi: number,
): Position3D => ({
x: radius * Math.sin(phi) * Math.cos(theta),
y: radius * Math.cos(phi),
z: radius * Math.sin(phi) * Math.sin(theta),
}),
calculateDistance: (
pos: Position3D,
center: Position3D = { x: 0, y: 0, z: 0 },
): number => {
const dx = pos.x - center.x;
const dy = pos.y - center.y;
const dz = pos.z - center.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
},
normalizeAngle: (angle: number): number => {
while (angle > 180) angle -= 360;
while (angle < -180) angle += 360;
return angle;
},
};
// ==========================================
// MAIN COMPONENT
// ==========================================
const SphereImageGrid: React.FC<SphereImageGridProps> = ({
images = [],
containerSize = 400,
sphereRadius = 200,
dragSensitivity = 0.5,
momentumDecay = 0.95,
maxRotationSpeed = 5,
baseImageScale = 0.12,
hoverScale: _hoverScale = 1.2,
perspective = 1000,
autoRotate = false,
autoRotateSpeed = 0.3,
className = "",
}) => {
// ==========================================
// STATE & REFS
// ==========================================
const [isMounted, setIsMounted] = useState<boolean>(false);
const [rotation, setRotation] = useState<RotationState>({
x: 15,
y: 15,
z: 0,
});
const [velocity, setVelocity] = useState<VelocityState>({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState<boolean>(false);
const [selectedImage, setSelectedImage] = useState<ImageData | null>(null);
const [imagePositions, setImagePositions] = useState<SphericalPosition[]>([]);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const lastMousePos = useRef<MousePosition>({ x: 0, y: 0 });
const animationFrame = useRef<number | null>(null);
// ==========================================
// COMPUTED VALUES
// ==========================================
const actualSphereRadius = sphereRadius || containerSize * 0.5;
const baseImageSize = containerSize * baseImageScale;
// ==========================================
// UTILITY FUNCTIONS
// ==========================================
const generateSpherePositions = useCallback((): SphericalPosition[] => {
const positions: SphericalPosition[] = [];
const imageCount = images.length;
// Use Fibonacci sphere distribution for even coverage
const goldenRatio = (1 + Math.sqrt(5)) / 2;
const angleIncrement = (2 * Math.PI) / goldenRatio;
for (let i = 0; i < imageCount; i++) {
// Fibonacci sphere distribution
const t = i / imageCount;
const inclination = Math.acos(1 - 2 * t);
const azimuth = angleIncrement * i;
// Convert to degrees and focus on front hemisphere
let phi = inclination * (180 / Math.PI);
let theta = (azimuth * (180 / Math.PI)) % 360;
// Better pole coverage - reach poles but avoid extreme mathematical issues
const poleBonus = (Math.abs(phi - 90) / 90) ** 0.6 * 35; // Moderate boost toward poles
if (phi < 90) {
phi = Math.max(5, phi - poleBonus); // Reach closer to top pole (15° minimum)
} else {
phi = Math.min(175, phi + poleBonus); // Reach closer to bottom pole (165° maximum)
}
// Map to fuller vertical range - covers poles but avoids extremes
phi = 15 + (phi / 180) * 150; // Map to 15-165 degrees for pole coverage with stability
// Add slight randomization to prevent perfect patterns
const randomOffset = (Math.random() - 0.5) * 20;
theta = (theta + randomOffset) % 360;
phi = Math.max(0, Math.min(180, phi + (Math.random() - 0.5) * 10));
positions.push({
theta: theta,
phi: phi,
radius: actualSphereRadius,
});
}
return positions;
}, [images.length, actualSphereRadius]);
const calculateWorldPositions = useCallback((): WorldPosition[] => {
const positions = imagePositions.map((pos, index) => {
// Apply rotation using proper 3D rotation matrices
const thetaRad = SPHERE_MATH.degreesToRadians(pos.theta);
const phiRad = SPHERE_MATH.degreesToRadians(pos.phi);
const rotXRad = SPHERE_MATH.degreesToRadians(rotation.x);
const rotYRad = SPHERE_MATH.degreesToRadians(rotation.y);
// Initial position on sphere
let x = pos.radius * Math.sin(phiRad) * Math.cos(thetaRad);
let y = pos.radius * Math.cos(phiRad);
let z = pos.radius * Math.sin(phiRad) * Math.sin(thetaRad);
// Apply Y-axis rotation (horizontal drag)
const x1 = x * Math.cos(rotYRad) + z * Math.sin(rotYRad);
const z1 = -x * Math.sin(rotYRad) + z * Math.cos(rotYRad);
x = x1;
z = z1;
// Apply X-axis rotation (vertical drag)
const y2 = y * Math.cos(rotXRad) - z * Math.sin(rotXRad);
const z2 = y * Math.sin(rotXRad) + z * Math.cos(rotXRad);
y = y2;
z = z2;
const worldPos: Position3D = { x, y, z };
// Calculate visibility with smooth fade zones
const fadeZoneStart = -10; // Start fading out
const fadeZoneEnd = -30; // Completely hidden
const isVisible = worldPos.z > fadeZoneEnd;
// Calculate fade opacity based on Z position
let fadeOpacity = 1;
if (worldPos.z <= fadeZoneStart) {
// Linear fade from 1 to 0 as Z goes from fadeZoneStart to fadeZoneEnd
fadeOpacity = Math.max(
0,
(worldPos.z - fadeZoneEnd) / (fadeZoneStart - fadeZoneEnd),
);
}
// Check if this image originated from a pole position
const isPoleImage = pos.phi < 30 || pos.phi > 150; // Images from extreme angles
// Calculate distance from center for scaling (in 2D screen space)
const distanceFromCenter = Math.sqrt(
worldPos.x * worldPos.x + worldPos.y * worldPos.y,
);
const maxDistance = actualSphereRadius;
const distanceRatio = Math.min(distanceFromCenter / maxDistance, 1);
// Scale based on distance from center - be more forgiving for pole images
const distancePenalty = isPoleImage ? 0.4 : 0.7; // Less penalty for pole images
const centerScale = Math.max(0.3, 1 - distanceRatio * distancePenalty);
// Also consider Z-depth for additional scaling
const depthScale =
(worldPos.z + actualSphereRadius) / (2 * actualSphereRadius);
const scale = centerScale * Math.max(0.5, 0.8 + depthScale * 0.3);
return {
...worldPos,
scale,
zIndex: Math.round(1000 + worldPos.z),
isVisible,
fadeOpacity,
originalIndex: index,
};
});
// Apply collision detection to prevent overlaps
const adjustedPositions = [...positions];
for (let i = 0; i < adjustedPositions.length; i++) {
const pos = adjustedPositions[i];
if (!pos?.isVisible) continue;
let adjustedScale = pos.scale;
const imageSize = baseImageSize * adjustedScale;
// Check for overlaps with other visible images
for (let j = 0; j < adjustedPositions.length; j++) {
if (i === j) continue;
const other = adjustedPositions[j];
if (!other?.isVisible) continue;
const otherSize = baseImageSize * other.scale;
// Calculate 2D distance between images on screen
const dx = pos.x - other.x;
const dy = pos.y - other.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Minimum distance to prevent overlap (with more generous padding)
const minDistance = (imageSize + otherSize) / 2 + 25;
if (distance < minDistance && distance > 0) {
// More aggressive scale reduction to prevent overlap
const overlap = minDistance - distance;
const reductionFactor = Math.max(
0.4,
1 - (overlap / minDistance) * 0.6,
);
adjustedScale = Math.min(
adjustedScale,
adjustedScale * reductionFactor,
);
}
}
adjustedPositions[i] = {
...pos,
scale: Math.max(0.25, adjustedScale), // Ensure minimum scale
zIndex: pos.zIndex ?? 0, // Ensure zIndex is a number
};
}
return adjustedPositions;
}, [imagePositions, rotation, actualSphereRadius, baseImageSize]);
const clampRotationSpeed = useCallback(
(speed: number): number => {
return Math.max(-maxRotationSpeed, Math.min(maxRotationSpeed, speed));
},
[maxRotationSpeed],
);
// ==========================================
// PHYSICS & MOMENTUM
// ==========================================
const updateMomentum = useCallback(() => {
if (isDragging) return;
setVelocity((prev) => {
const newVelocity = {
x: prev.x * momentumDecay,
y: prev.y * momentumDecay,
};
// Stop animation if velocity is too low and auto-rotate is off
if (
!autoRotate &&
Math.abs(newVelocity.x) < 0.01 &&
Math.abs(newVelocity.y) < 0.01
) {
return { x: 0, y: 0 };
}
return newVelocity;
});
setRotation((prev) => {
let newY = prev.y;
// Add auto-rotation to Y axis (horizontal rotation)
if (autoRotate) {
newY += autoRotateSpeed;
}
// Add momentum-based rotation
newY += clampRotationSpeed(velocity.y);
return {
x: SPHERE_MATH.normalizeAngle(prev.x + clampRotationSpeed(velocity.x)),
y: SPHERE_MATH.normalizeAngle(newY),
z: prev.z,
};
});
}, [
isDragging,
momentumDecay,
velocity,
clampRotationSpeed,
autoRotate,
autoRotateSpeed,
]);
// ==========================================
// EVENT HANDLERS
// ==========================================
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
setVelocity({ x: 0, y: 0 });
lastMousePos.current = { x: e.clientX, y: e.clientY };
}, []);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
const deltaX = e.clientX - lastMousePos.current.x;
const deltaY = e.clientY - lastMousePos.current.y;
const rotationDelta = {
x: -deltaY * dragSensitivity,
y: deltaX * dragSensitivity,
};
setRotation((prev) => ({
x: SPHERE_MATH.normalizeAngle(
prev.x + clampRotationSpeed(rotationDelta.x),
),
y: SPHERE_MATH.normalizeAngle(
prev.y + clampRotationSpeed(rotationDelta.y),
),
z: prev.z,
}));
// Update velocity for momentum
setVelocity({
x: clampRotationSpeed(rotationDelta.x),
y: clampRotationSpeed(rotationDelta.y),
});
lastMousePos.current = { x: e.clientX, y: e.clientY };
},
[isDragging, dragSensitivity, clampRotationSpeed],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
e.preventDefault();
const touch = e.touches[0];
if (touch) {
setIsDragging(true);
setVelocity({ x: 0, y: 0 });
lastMousePos.current = { x: touch.clientX, y: touch.clientY };
}
}, []);
const handleTouchMove = useCallback(
(e: TouchEvent) => {
if (!isDragging) return;
e.preventDefault();
const touch = e.touches[0];
if (!touch) return;
const deltaX = touch.clientX - lastMousePos.current.x;
const deltaY = touch.clientY - lastMousePos.current.y;
const rotationDelta = {
x: -deltaY * dragSensitivity,
y: deltaX * dragSensitivity,
};
setRotation((prev) => ({
x: SPHERE_MATH.normalizeAngle(
prev.x + clampRotationSpeed(rotationDelta.x),
),
y: SPHERE_MATH.normalizeAngle(
prev.y + clampRotationSpeed(rotationDelta.y),
),
z: prev.z,
}));
setVelocity({
x: clampRotationSpeed(rotationDelta.x),
y: clampRotationSpeed(rotationDelta.y),
});
lastMousePos.current = { x: touch.clientX, y: touch.clientY };
},
[isDragging, dragSensitivity, clampRotationSpeed],
);
const handleTouchEnd = useCallback(() => {
setIsDragging(false);
}, []);
// ==========================================
// EFFECTS & LIFECYCLE
// ==========================================
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
setImagePositions(generateSpherePositions());
}, [generateSpherePositions]);
useEffect(() => {
const animate = () => {
updateMomentum();
animationFrame.current = requestAnimationFrame(animate);
};
if (isMounted) {
animationFrame.current = requestAnimationFrame(animate);
}
return () => {
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current);
}
};
}, [isMounted, updateMomentum]);
useEffect(() => {
if (!isMounted) return;
const container = containerRef.current;
if (!container) return;
// Mouse events
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
// Touch events
document.addEventListener("touchmove", handleTouchMove, { passive: false });
document.addEventListener("touchend", handleTouchEnd);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
};
}, [
isMounted,
handleMouseMove,
handleMouseUp,
handleTouchMove,
handleTouchEnd,
]);
// ==========================================
// RENDER HELPERS
// ==========================================
// Calculate world positions once per render
const worldPositions = calculateWorldPositions();
const renderImageNode = useCallback(
(image: ImageData, index: number) => {
const position = worldPositions[index];
if (!position || !position.isVisible) return null;
const imageSize = baseImageSize * position.scale;
const isHovered = hoveredIndex === index;
const finalScale = isHovered ? Math.min(1.2, 1.2 / position.scale) : 1;
return (
<div
key={image.id}
role="button"
tabIndex={0}
className="absolute cursor-pointer select-none transition-transform duration-200 ease-out"
style={{
width: `${imageSize}px`,
height: `${imageSize}px`,
left: `${containerSize / 2 + position.x}px`,
top: `${containerSize / 2 + position.y}px`,
opacity: position.fadeOpacity,
transform: `translate(-50%, -50%) scale(${finalScale})`,
zIndex: position.zIndex,
}}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => setSelectedImage(image)}
onKeyDown={(e) => e.key === "Enter" && setSelectedImage(image)}
>
<div className="relative h-full w-full overflow-hidden rounded-full border-2 border-white/20 shadow-lg">
<Image
src={image.src}
alt={image.alt}
fill
className="object-cover"
draggable={false}
loading={index < 3 ? "eager" : "lazy"}
unoptimized
/>
</div>
</div>
);
},
[worldPositions, baseImageSize, containerSize, hoveredIndex],
);
const renderSpotlightModal = () => {
if (!selectedImage) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
onClick={() => setSelectedImage(null)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Escape" && setSelectedImage(null)}
style={{
animation: "fadeIn 0.3s ease-out",
}}
>
<div
className="w-full max-w-md overflow-hidden rounded-xl bg-white"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
style={{
animation: "scaleIn 0.3s ease-out",
}}
>
<div className="relative aspect-square">
<Image
src={selectedImage.src}
alt={selectedImage.alt}
fill
className="object-cover"
unoptimized
/>
<button
onClick={() => setSelectedImage(null)}
className="absolute top-2 right-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-black bg-opacity-50 text-white transition-all hover:bg-opacity-70"
>
<X size={16} />
</button>
</div>
{(selectedImage.title || selectedImage.description) && (
<div className="p-6">
{selectedImage.title && (
<h3 className="mb-2 font-bold text-xl">
{selectedImage.title}
</h3>
)}
{selectedImage.description && (
<p className="text-gray-600">{selectedImage.description}</p>
)}
</div>
)}
</div>
</div>
);
};
// ==========================================
// EARLY RETURNS
// ==========================================
if (!isMounted) {
return (
<div
className="flex animate-pulse items-center justify-center rounded-lg bg-gray-100"
style={{ width: containerSize, height: containerSize }}
>
<div className="text-gray-400">Loading...</div>
</div>
);
}
if (!images.length) {
return (
<div
className="flex items-center justify-center rounded-lg border-2 border-gray-300 border-dashed bg-gray-50"
style={{ width: containerSize, height: containerSize }}
>
<div className="text-center text-gray-400">
<p>No images provided</p>
<p className="text-sm">Add images to the images prop</p>
</div>
</div>
);
}
// ==========================================
// MAIN RENDER
// ==========================================
return (
<>
<style>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
`}</style>
<div
ref={containerRef}
className={`relative cursor-grab select-none active:cursor-grabbing ${className}`}
style={{
width: containerSize,
height: containerSize,
perspective: `${perspective}px`,
}}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
role="button"
aria-label="Interactive 3D Image Sphere"
tabIndex={0}
>
<div className="relative h-full w-full" style={{ zIndex: 10 }}>
{images.map((image, index) => renderImageNode(image, index))}
</div>
</div>
{renderSpotlightModal()}
</>
);
};
export SphereImageGrid;Usage
import SphereImageGrid from "@/components/ui/image-sphere";
const images = [
{
id: "1",
src: "https://example.com/image1.jpg",
alt: "Image 1",
title: "Title",
description: "Description",
},
// Add more images...
];
export default function MyComponent() {
return (
<SphereImageGrid
images={images}
containerSize={400}
sphereRadius={180}
autoRotate={true}
/>
);
}API Reference
SphereImageGrid
Prop
Type
ImageData
Prop
Type
Position3D
Prop
Type
SphericalPosition
Prop
Type
Notes
- The sphere uses Fibonacci distribution for even image placement across the surface
- Supports both mouse and touch interactions for drag-based rotation
- Images smoothly fade out when rotating to the back of the sphere
- Click on any image to open a spotlight modal with title and description
- Built-in momentum physics provides natural deceleration after dragging
- Collision detection prevents image overlap during rotation
- Auto-rotate can be interrupted by user interaction and resumes automatically
- Responsive container sizing with configurable perspective for 3D depth
How is this guide?
Image Comparison
Before/after image comparison slider for React. Drag handle, hover reveal, and lens modes. Perfect for photo editing, design, and AI image showcases.
Magnetic
Magnetic hover effect component for React. Elements smoothly follow cursor with spring physics. Perfect for buttons, cards, and interactive UI.