Kanso UI LogoKanso UI
Components/sphere carousel
data-display

Sphere Carousel

A premium 3D holographic sphere carousel distributing circular image cards evenly via Fibonacci points, featuring dual-axis drag inertia, autoplay spinning, and camera depth-of-field blur.

3dcarouselsphereorbitgallerydraginertiainteractive

Preview

Sphere gallery item
Sphere gallery item
Sphere gallery item
Sphere gallery item
Sphere gallery item
Sphere gallery item
Sphere gallery item
Sphere gallery item
Sphere gallery item
Sphere gallery item
Drag in any direction to spinArrows to steerSpace to play
Adjust Sphere Carousel Parameters
Sphere Radius200px
Card Dimension Size100px
Enable Idle Autospin
* Drag the sphere in any direction (X or Y coordinates) to rotate. Release to spin with inertia. Keyboard Focus support: select container and use Arrow keys to steer, Space to toggle Autospin.

🖲Usage

example-usage.tsx
import { SphereCarousel } from "@/components/kanso/sphere-carousel"

export default function SphereCarouselDemo() {
  return (
    <div className="w-full h-[550px] overflow-hidden">
      <SphereCarousel />
    </div>
  )
}

Installation

1

Create folder & copy source

Create a folder named kanso inside your project's components directory (i.e. components/kanso/). Copy the source code shown in the next section, and paste it into a file named sphere-carousel.tsx inside it.

2

Required helper files

Ensure your project has the following helper files configured:

  • lib/utils

<>Source Code

sphere-carousel.tsx
'use client';

import * as React from 'react';
import { cn } from '@/lib/utils';

/* 1️⃣  Assets & Placeholders ————————————————————————— */
const FALLBACK =
  'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" ' +
  'width="100" height="100"><rect width="100%" height="100%" ' +
  'fill="%2327272a"/><circle cx="50%" cy="50%" r="40%" fill="%233f3f46"/>' +
  '<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" ' +
  'fill="%23a1a1aa" font-size="12">Image</text></svg>';

const DEFAULT_IMAGES = [
  'https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?auto=format&fit=crop&q=80&w=300&h=300',
  'https://images.unsplash.com/photo-1578632767115-351597cf2477?auto=format&fit=crop&q=80&w=300&h=300',
  'https://images.unsplash.com/photo-1541562232579-512a21360020?auto=format&fit=crop&q=80&w=300&h=300',
  'https://images.unsplash.com/photo-1503899036084-c55cdd92da26?auto=format&fit=crop&q=80&w=300&h=300',
  'https://images.unsplash.com/photo-1528164344705-47542687000d?auto=format&fit=crop&q=80&w=300&h=300',
  'https://images.unsplash.com/photo-1518709268805-4e9042af9f23?auto=format&fit=crop&q=80&w=300&h=300',
  'https://images.unsplash.com/photo-1509198397868-475647b2a1e5?auto=format&fit=crop&q=80&w=300&h=300',
  'https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?auto=format&fit=crop&q=80&w=300&h=300',
  'https://images.unsplash.com/photo-1516979187457-637abb4f9353?auto=format&fit=crop&q=80&w=300&h=300',
  'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=300&h=300',
];

/* 2️⃣  Config Defaults ————————————————————————— */
const RADIUS = 200;
const CARD_SIZE = 100;
const DRAG_SENSITIVITY = 0.35;
const INERTIA_FRICTION = 0.95;
const AUTOSPIN_SPEED = 0.05;
const IDLE_TIMEOUT = 2000;

/* 3️⃣  Card Component ————————————————— */
interface CardProps {
  src: string;
  cardSize: number;
  innerRef: (el: HTMLDivElement | null) => void;
}

const Card = React.memo(({ src, cardSize, innerRef }: CardProps) => (
  <div
    ref={innerRef}
    className="absolute origin-center group/card cursor-pointer"
    style={{
      width: cardSize,
      height: cardSize,
      willChange: 'transform, opacity, filter',
      position: 'absolute',
      left: '50%',
      top: '50%',
      marginLeft: -cardSize / 2,
      marginTop: -cardSize / 2,
    }}
  >
    <div
      className="relative size-full rounded-full overflow-hidden bg-zinc-900
                 border border-zinc-200/10 dark:border-zinc-800/60 shadow-lg
                 transition-transform duration-500 ease-out group-hover/card:scale-110 group-hover/card:shadow-2xl group-hover/card:border-zinc-400/30"
    >
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src={src}
        alt="Sphere gallery item"
        width={cardSize}
        height={cardSize}
        className="size-full object-cover select-none pointer-events-none"
        loading="lazy"
        draggable="false"
        onError={(e) => {
          e.currentTarget.src = FALLBACK;
        }}
      />
      {/* Dynamic light gradient sheen */}
      <div
        className="absolute inset-0 z-10 opacity-0 group-hover/card:opacity-100 transition-opacity duration-700 pointer-events-none"
        style={{
          background:
            'radial-gradient(circle at 35% 35%, rgba(255, 255, 255, 0.15) 0%, transparent 60%)',
        }}
      />
    </div>
  </div>
));

Card.displayName = 'Card';

/* 4️⃣  Main Component —————————————————— */
export interface SphereCarouselProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Array of image URLs to distribute on the sphere */
  images?: string[];
  /** Radius of the sphere in pixels (default: 200) */
  radius?: number;
  /** Dimension size (width and height) of the circular cards in pixels (default: 100) */
  cardSize?: number;
  /** Whether the sphere auto-spins when idle (default: true) */
  autoSpin?: boolean;
}

const SphereCarousel = React.forwardRef<HTMLDivElement, SphereCarouselProps>(
  (
    {
      images = DEFAULT_IMAGES,
      radius = RADIUS,
      cardSize = CARD_SIZE,
      autoSpin = true,
      className,
      style,
      ...props
    },
    ref
  ) => {
    const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]);

    const rotationXRef = React.useRef(0);
    const rotationYRef = React.useRef(0);

    const velocityXRef = React.useRef(0);
    const velocityYRef = React.useRef(0);

    const isDraggingRef = React.useRef(false);
    const dragStartRef = React.useRef({ x: 0, y: 0 });
    const initialRotationRef = React.useRef({ x: 0, y: 0 });
    const lastRotationRef = React.useRef({ x: 0, y: 0 });

    const lastInteractionRef = React.useRef(Date.now());
    const animationFrameRef = React.useRef<number | null>(null);
    const [isAutospinActive, setIsAutospinActive] = React.useState(autoSpin);

    // Resolve refs
    const localRef = React.useRef<HTMLDivElement>(null);
    const resolvedRef = (ref ||
      localRef) as React.RefObject<HTMLDivElement | null>;

    // Generate static point vectors on the sphere surface using Fibonacci Sphere distribution
    const basePoints = React.useMemo(() => {
      const N = images.length;
      const points = [];
      const goldenAngle = Math.PI * (3.0 - Math.sqrt(5.0));

      for (let i = 0; i < N; i++) {
        // Distribute y coordinates evenly from 1 to -1
        const yVal = N === 1 ? 0 : 1.0 - (i / (N - 1)) * 2.0;
        const radiusAtY = Math.sqrt(1.0 - yVal * yVal);

        const theta = goldenAngle * i;

        const xVal = Math.cos(theta) * radiusAtY;
        const zVal = Math.sin(theta) * radiusAtY;

        points.push({
          x: xVal * radius,
          y: yVal * radius,
          z: zVal * radius,
        });
      }
      return points;
    }, [images.length, radius]);

    // Physics Animation Engine
    React.useEffect(() => {
      const animate = () => {
        if (!isDraggingRef.current) {
          // Apply velocity dampening
          if (Math.abs(velocityYRef.current) > 0.01) {
            rotationYRef.current += velocityYRef.current;
            velocityYRef.current *= INERTIA_FRICTION;
          }
          if (Math.abs(velocityXRef.current) > 0.01) {
            rotationXRef.current += velocityXRef.current;
            velocityXRef.current *= INERTIA_FRICTION;
          }

          // Idle autoplay spinning
          if (
            isAutospinActive &&
            Date.now() - lastInteractionRef.current > IDLE_TIMEOUT
          ) {
            rotationYRef.current += AUTOSPIN_SPEED;
            // Introduce a subtle vertical wobble
            rotationXRef.current += AUTOSPIN_SPEED * 0.3;
          }
        }

        // Apply 3D Rotations & Billboarding
        const radY = (rotationYRef.current * Math.PI) / 180;
        const cosY = Math.cos(radY);
        const sinY = Math.sin(radY);

        const radX = (rotationXRef.current * Math.PI) / 180;
        const cosX = Math.cos(radX);
        const sinX = Math.sin(radX);

        basePoints.forEach((pt, idx) => {
          const cardEl = cardRefs.current[idx];
          if (!cardEl) return;

          // 1. Rotate Y-axis (left/right)
          const x1 = pt.x * cosY - pt.z * sinY;
          const z1 = pt.x * sinY + pt.z * cosY;

          // 2. Rotate X-axis (up/down)
          const y2 = pt.y * cosX - z1 * sinX;
          const z2 = pt.y * sinX + z1 * cosX;

          // Normalize depth mapping (z2 ranges from -radius to +radius)
          const depth = (z2 + radius) / (2 * radius); // 0 (back) to 1 (front)

          // Premium visual properties mapped to coordinate depth
          const scale = 0.55 + 0.45 * depth; // Scale size
          const opacity = 0.2 + 0.8 * depth; // Fade intensity
          const blurAmount = (1 - depth) * 4.5; // Depth-of-Field blur
          const zIndex = Math.round(depth * 100); // Visual sorting layer

          // Direct DOM updates for maximum performance
          cardEl.style.transform = `translate3d(${x1.toFixed(1)}px, ${y2.toFixed(1)}px, ${z2.toFixed(1)}px) scale(${scale.toFixed(2)})`;
          cardEl.style.opacity = opacity.toFixed(2);
          cardEl.style.filter =
            blurAmount > 0.15 ? `blur(${blurAmount.toFixed(2)}px)` : 'none';
          cardEl.style.zIndex = zIndex.toString();
        });

        animationFrameRef.current = requestAnimationFrame(animate);
      };

      animationFrameRef.current = requestAnimationFrame(animate);

      return () => {
        if (animationFrameRef.current) {
          cancelAnimationFrame(animationFrameRef.current);
        }
      };
    }, [basePoints, isAutospinActive, radius]);

    // Handle Drag Actions
    const handleDragStart = React.useCallback(
      (clientX: number, clientY: number) => {
        lastInteractionRef.current = Date.now();
        isDraggingRef.current = true;
        velocityXRef.current = 0;
        velocityYRef.current = 0;
        dragStartRef.current = { x: clientX, y: clientY };
        initialRotationRef.current = {
          x: rotationXRef.current,
          y: rotationYRef.current,
        };
        lastRotationRef.current = {
          x: rotationXRef.current,
          y: rotationYRef.current,
        };
      },
      []
    );

    const handleDragMove = React.useCallback(
      (clientX: number, clientY: number) => {
        if (!isDraggingRef.current) return;
        lastInteractionRef.current = Date.now();

        const deltaX = clientX - dragStartRef.current.x;
        const deltaY = clientY - dragStartRef.current.y;

        rotationYRef.current =
          initialRotationRef.current.y + deltaX * DRAG_SENSITIVITY;
        rotationXRef.current =
          initialRotationRef.current.x - deltaY * DRAG_SENSITIVITY;

        // Track speed vector for inertia calculations on release
        velocityYRef.current = rotationYRef.current - lastRotationRef.current.y;
        velocityXRef.current = rotationXRef.current - lastRotationRef.current.x;

        lastRotationRef.current = {
          x: rotationXRef.current,
          y: rotationYRef.current,
        };
      },
      []
    );

    const handleDragEnd = React.useCallback(() => {
      isDraggingRef.current = false;
      lastInteractionRef.current = Date.now();
    }, []);

    // Interactive event bindings
    const onMouseDown = (e: React.MouseEvent) => {
      if (e.button !== 0) return; // Left click only
      handleDragStart(e.clientX, e.clientY);
    };

    const onMouseMove = (e: React.MouseEvent) =>
      handleDragMove(e.clientX, e.clientY);
    const onTouchStart = (e: React.TouchEvent) =>
      handleDragStart(e.touches[0].clientX, e.touches[0].clientY);
    const onTouchMove = (e: React.TouchEvent) =>
      handleDragMove(e.touches[0].clientX, e.touches[0].clientY);

    // Keyboard navigation
    const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
      lastInteractionRef.current = Date.now();
      velocityXRef.current = 0;
      velocityYRef.current = 0;

      if (e.key === 'ArrowLeft') {
        e.preventDefault();
        rotationYRef.current += 10;
      } else if (e.key === 'ArrowRight') {
        e.preventDefault();
        rotationYRef.current -= 10;
      } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        rotationXRef.current += 10;
      } else if (e.key === 'ArrowDown') {
        e.preventDefault();
        rotationXRef.current -= 10;
      } else if (e.key === ' ' || e.key === 'Spacebar') {
        e.preventDefault();
        setIsAutospinActive((prev) => !prev);
      }
    };

    return (
      <div
        ref={resolvedRef}
        role="region"
        aria-label="3D Spherical Image Gallery"
        tabIndex={0}
        onKeyDown={handleKeyDown}
        onMouseDown={onMouseDown}
        onMouseMove={onMouseMove}
        onMouseUp={handleDragEnd}
        onMouseLeave={handleDragEnd}
        onTouchStart={onTouchStart}
        onTouchMove={onTouchMove}
        onTouchEnd={handleDragEnd}
        className={cn(
          'w-full h-full flex flex-col items-center justify-center overflow-hidden font-sans outline-none select-none relative',
          'cursor-grab active:cursor-grabbing border border-zinc-200/50 dark:border-zinc-800/40 rounded-2xl bg-zinc-50/10 dark:bg-zinc-950/20',
          'focus-visible:ring-1 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-700',
          className
        )}
        style={{ ...style, userSelect: 'none' }}
        {...props}
      >
        {/* Subtle holographic core background glow */}
        <div className="absolute inset-0 pointer-events-none z-0 bg-[radial-gradient(circle_at_center,transparent_35%,rgba(9,9,11,0.12)_100%)] dark:bg-[radial-gradient(circle_at_center,transparent_35%,rgba(0,0,0,0.5)_100%)]" />

        {/* 3D Space viewport */}
        <div
          className="relative flex items-center justify-center z-10"
          style={{
            perspective: 800,
            perspectiveOrigin: 'center',
            width: radius * 2 + cardSize * 1.5,
            height: radius * 2 + cardSize * 1.5,
          }}
        >
          {/* Central Origin coordinate offset */}
          <div className="relative size-0 transform-style-3d">
            {images.map((src, idx) => (
              <Card
                key={`sphere-card-${idx}`}
                src={src}
                cardSize={cardSize}
                innerRef={(el) => {
                  cardRefs.current[idx] = el;
                }}
              />
            ))}
          </div>
        </div>

        {/* Ambient guidelines help text */}
        <div className="absolute bottom-4 flex items-center gap-4 text-[10px] text-zinc-450 dark:text-zinc-500 font-mono pointer-events-none tracking-wider uppercase z-20">
          <span>Drag in any direction to spin</span>
          <span className="text-zinc-300 dark:text-zinc-800">•</span>
          <span>Arrows to steer</span>
          <span className="text-zinc-300 dark:text-zinc-800">•</span>
          <span>Space to play</span>
        </div>
      </div>
    );
  }
);

SphereCarousel.displayName = 'SphereCarousel';

export { SphereCarousel };

Props

Command Palette

Search for a command to run...