ComponentsCreative

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

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 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?

On this page