Components

Image Sphere

An interactive 3D sphere grid that displays images with drag rotation, momentum physics, and click-to-expand functionality.

Last updated on

Edit on GitHub

Basic Usage

Open in
Loading...
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.

Open in
Loading...
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>
  );
}

Display more images with adjusted sizing for better visibility.

Open in
Loading...
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.

Open in
Loading...
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-react

Copy and paste the following code into your project. component/ui/image-sphere.tsx

import { X } from 'lucide-react';
import React, { 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 = 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.pow(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
      };
    }
 
    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];
    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];
    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}
        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)}
      >
        <div className="relative w-full h-full rounded-full overflow-hidden shadow-lg border-2 border-white/20">
          <img
            src={image.src}
            alt={image.alt}
            className="w-full h-full object-cover"
            draggable={false}
            loading={index < 3 ? 'eager' : 'lazy'}
          />
        </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 p-4 bg-black/30"
        onClick={() => setSelectedImage(null)}
        style={{
          animation: 'fadeIn 0.3s ease-out'
        }}
      >
        <div
          className="bg-white rounded-xl max-w-md w-full overflow-hidden"
          onClick={(e) => e.stopPropagation()}
          style={{
            animation: 'scaleIn 0.3s ease-out'
          }}
        >
          <div className="relative aspect-square">
            <img
              src={selectedImage.src}
              alt={selectedImage.alt}
              className="w-full h-full object-cover"
            />
            <button
              onClick={() => setSelectedImage(null)}
              className="absolute top-2 right-2 w-8 h-8 bg-black bg-opacity-50 rounded-full text-white flex items-center justify-center hover:bg-opacity-70 transition-all cursor-pointer"
            >
              <X size={16} />
            </button>
          </div>
 
          {(selectedImage.title || selectedImage.description) && (
            <div className="p-6">
              {selectedImage.title && (
                <h3 className="text-xl font-bold mb-2">{selectedImage.title}</h3>
              )}
              {selectedImage.description && (
                <p className="text-gray-600">{selectedImage.description}</p>
              )}
            </div>
          )}
        </div>
      </div>
    );
  };
 
  // ==========================================
  // EARLY RETURNS
  // ==========================================
 
  if (!isMounted) {
    return (
      <div
        className="bg-gray-100 rounded-lg animate-pulse flex items-center justify-center"
        style={{ width: containerSize, height: containerSize }}
      >
        <div className="text-gray-400">Loading...</div>
      </div>
    );
  }
 
  if (!images.length) {
    return (
      <div
        className="bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center"
        style={{ width: containerSize, height: containerSize }}
      >
        <div className="text-gray-400 text-center">
          <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 select-none cursor-grab active:cursor-grabbing ${className}`}
        style={{
          width: containerSize,
          height: containerSize,
          perspective: `${perspective}px`
        }}
        onMouseDown={handleMouseDown}
        onTouchStart={handleTouchStart}
      >
        <div className="relative w-full h-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?

On this page