Kanso UI LogoKanso UI
Components/three d-carousel
data-display

3D Carousel

A lightweight, high-performance 3D image carousel with physics-based drag inertia, autoplay spin, and cursor hover tilting.

3dcarouselgalleryorbitdraginertiainteractive

▶Preview

Carousel gallery item
Carousel gallery item
Carousel gallery item
Carousel gallery item
Carousel gallery item
Carousel gallery item
Drag or Left/Right arrows to rotate•Space to toggle play
Adjust 3D Carousel Parameters
Orbit Radius240px
Card Width180px
Card Height240px
Enable Autospin When Idle
* Drag cards vertically or horizontally to spin with inertia. Move mouse up/down to tilt perspective. Keyboard Focus support: select container and use ArrowLeft/Right to manually step, Space to toggle Autospin.

🖲Usage

example-usage.tsx
import { ThreeDCarousel } from "@/components/kanso/three-d-carousel"

export default function ThreeDCarouselDemo() {
  return (
    <div className="w-full h-[500px] overflow-hidden">
      <ThreeDCarousel />
    </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 three-d-carousel.tsx inside it.

2

Required helper files

Ensure your project has the following helper files configured:

  • →lib/utils

<>Source Code

three-d-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="160" height="220"><rect width="100%" height="100%" ' +
  'fill="%2327272a"/><text x="50%" y="50%" dominant-baseline="middle"' +
  ' text-anchor="middle" fill="%2371717a" font-size="14">Image</text></svg>';

const DEFAULT_IMAGES = [
  'https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?auto=format&fit=crop&q=80&w=600&h=800',
  'https://images.unsplash.com/photo-1578632767115-351597cf2477?auto=format&fit=crop&q=80&w=600&h=800',
  'https://images.unsplash.com/photo-1541562232579-512a21360020?auto=format&fit=crop&q=80&w=600&h=800',
  'https://images.unsplash.com/photo-1503899036084-c55cdd92da26?auto=format&fit=crop&q=80&w=600&h=800',
  'https://images.unsplash.com/photo-1528164344705-47542687000d?auto=format&fit=crop&q=80&w=600&h=800',
  'https://images.unsplash.com/photo-1518709268805-4e9042af9f23?auto=format&fit=crop&q=80&w=600&h=800',
];

/* 2️⃣  Config Defaults ————————————————————————— */
const CARD_W = 180;
const CARD_H = 240;
const RADIUS = 240;
const TILT_SENSITIVITY = 10;
const DRAG_SENSITIVITY = 0.5;
const INERTIA_FRICTION = 0.95;
const AUTOSPIN_SPEED = 0.08;
const IDLE_TIMEOUT = 2000;

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

const Card = React.memo(
  ({ src, transform, cardW, cardH, innerRef }: CardProps) => (
    <div
      ref={innerRef}
      className="absolute group/card cursor-pointer"
      style={{
        width: cardW,
        height: cardH,
        transform,
        transformStyle: 'preserve-3d',
        willChange: 'transform, opacity, filter',
        // Reflection progressive enhancement for WebKit/Blink
        WebkitBoxReflect:
          'below 12px linear-gradient(transparent 60%, rgba(0, 0, 0, 0.15))',
      }}
    >
      <div
        className="relative size-full rounded-2xl 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-[1.03] group-hover/card:shadow-2xl"
      >
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={src}
          alt="Carousel gallery item"
          width={cardW}
          height={cardH}
          className="size-full object-cover select-none pointer-events-none"
          loading="lazy"
          draggable="false"
          onError={(e) => {
            e.currentTarget.src = FALLBACK;
          }}
        />

        {/* Glossy reflection glass sheen overlay on hover */}
        <div
          className="absolute inset-0 z-10 opacity-0 group-hover/card:opacity-100 transition-opacity duration-700 pointer-events-none"
          style={{
            background:
              'linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0) 100%)',
          }}
        />
      </div>
    </div>
  )
);

Card.displayName = 'Card';

/* 4️⃣  Main Component —————————————————— */
export interface ThreeDCarouselProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Array of image source URLs */
  images?: string[];
  /** Outer orbit cylinder radius (default: 240) */
  radius?: number;
  /** Width of each card in pixels (default: 180) */
  cardW?: number;
  /** Height of each card in pixels (default: 240) */
  cardH?: number;
  /** Whether autospin is active by default (default: true) */
  autoSpin?: boolean;
}

const ThreeDCarousel = React.forwardRef<HTMLDivElement, ThreeDCarouselProps>(
  (
    {
      images = DEFAULT_IMAGES,
      radius = RADIUS,
      cardW = CARD_W,
      cardH = CARD_H,
      autoSpin = true,
      className,
      style,
      ...props
    },
    ref
  ) => {
    const parentRef = React.useRef<HTMLDivElement>(null);
    const wheelRef = React.useRef<HTMLDivElement>(null);
    const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]);

    const rotationRef = React.useRef(0);
    const tiltRef = React.useRef(0);
    const targetTiltRef = React.useRef(0);
    const velocityRef = React.useRef(0);
    const isDraggingRef = React.useRef(false);
    const dragStartRef = React.useRef(0);
    const initialRotationRef = React.useRef(0);
    const lastInteractionRef = React.useRef(Date.now());
    const animationFrameRef = React.useRef<number | null>(null);
    const [isAutospinActive, setIsAutospinActive] = React.useState(autoSpin);

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

    // Handle mouse movement for ambient perspective tilting
    React.useEffect(() => {
      const handleMouseMove = (e: MouseEvent) => {
        const container = resolvedRef.current;
        if (!container || isDraggingRef.current) return;

        lastInteractionRef.current = Date.now();
        const rect = container.getBoundingClientRect();
        const mouseY = e.clientY - rect.top;
        const normalizedY = (mouseY / rect.height - 0.5) * 2;

        targetTiltRef.current = -normalizedY * TILT_SENSITIVITY;
      };

      window.addEventListener('mousemove', handleMouseMove);
      return () => {
        window.removeEventListener('mousemove', handleMouseMove);
      };
    }, [resolvedRef]);

    // Carousel Physics & Camera Depth-of-Field Animation Engine
    React.useEffect(() => {
      const animate = () => {
        if (!isDraggingRef.current) {
          // Apply rotation inertia
          if (Math.abs(velocityRef.current) > 0.01) {
            rotationRef.current += velocityRef.current;
            velocityRef.current *= INERTIA_FRICTION;
          } else if (
            isAutospinActive &&
            Date.now() - lastInteractionRef.current > IDLE_TIMEOUT
          ) {
            rotationRef.current += AUTOSPIN_SPEED;
          }
        }

        tiltRef.current += (targetTiltRef.current - tiltRef.current) * 0.1;

        if (wheelRef.current) {
          wheelRef.current.style.transform = `rotateX(${tiltRef.current}deg) rotateY(${rotationRef.current}deg)`;
        }

        // Apply Premium Depth-of-Field camera focus mapping
        const totalCards = images.length;
        cardRefs.current.forEach((cardEl, idx) => {
          if (!cardEl) return;

          // Calculate absolute visual angle of this card relative to front camera view
          const cardBaseAngle = (idx * 360) / totalCards;
          const absoluteAngle = cardBaseAngle + rotationRef.current;

          // Cosine of current angle represents Z depth (-1 is back, 1 is front)
          const cos = Math.cos((absoluteAngle * Math.PI) / 180);
          const depth = (cos + 1) / 2; // 0 (back of carousel) to 1 (front center of carousel)

          // 1. Map depth to opacity: front cards are opaque, back cards fade out softly
          const opacity = 0.25 + 0.75 * depth;
          cardEl.style.opacity = opacity.toFixed(2);

          // 2. Map depth to blur: back cards undergo cinematic blur
          const blurAmount = (1 - depth) * 3.5; // Up to 3.5px blur at the very back
          cardEl.style.filter =
            blurAmount > 0.15 ? `blur(${blurAmount.toFixed(2)}px)` : 'none';

          // 3. Map depth to visual z-index stacking layers
          const zIndex = Math.round(depth * 100);
          cardEl.style.zIndex = zIndex.toString();
        });

        animationFrameRef.current = requestAnimationFrame(animate);
      };

      animationFrameRef.current = requestAnimationFrame(animate);

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

    const handleDragStart = React.useCallback((clientX: number) => {
      lastInteractionRef.current = Date.now();
      isDraggingRef.current = true;
      velocityRef.current = 0;
      dragStartRef.current = clientX;
      initialRotationRef.current = rotationRef.current;
    }, []);

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

      const deltaX = clientX - dragStartRef.current;
      const newRotation =
        initialRotationRef.current + deltaX * DRAG_SENSITIVITY;

      velocityRef.current = newRotation - rotationRef.current;
      rotationRef.current = newRotation;
    }, []);

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

    // Drag event handlers
    const onMouseDown = (e: React.MouseEvent) => {
      if (e.button !== 0) return; // Only left click
      handleDragStart(e.clientX);
    };

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

    // Keyboard accessibility navigation
    const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
      lastInteractionRef.current = Date.now();
      if (e.key === 'ArrowLeft') {
        e.preventDefault();
        velocityRef.current = 0;
        rotationRef.current += 15; // Rotate clockwise
      } else if (e.key === 'ArrowRight') {
        e.preventDefault();
        velocityRef.current = 0;
        rotationRef.current -= 15; // Rotate counter-clockwise
      } else if (e.key === ' ' || e.key === 'Spacebar') {
        e.preventDefault();
        setIsAutospinActive((prev) => !prev);
      }
    };

    // Pre-compute 3D position vectors for card layouts
    const cards = React.useMemo(
      () =>
        images.map((src, idx) => {
          const angle = (idx * 360) / images.length;
          return {
            key: idx,
            src,
            transform: `rotateY(${angle}deg) translateZ(${radius}px)`,
          };
        }),
      [images, radius]
    );

    return (
      <div
        ref={resolvedRef}
        role="region"
        aria-label="3D Image Gallery Carousel"
        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 atmospheric vignette backdrop */}
        <div className="absolute inset-0 pointer-events-none z-0 bg-[radial-gradient(circle_at_center,transparent_40%,rgba(9,9,11,0.15)_100%)] dark:bg-[radial-gradient(circle_at_center,transparent_40%,rgba(0,0,0,0.45)_100%)]" />

        <div
          className="relative flex items-center justify-center z-10"
          style={{
            perspective: 1000,
            perspectiveOrigin: 'center',
            width: Math.max(cardW * 1.5, radius * 2.2),
            height: Math.max(cardH * 1.8, radius * 1.5),
          }}
        >
          {/* Cylinder Wheel Container */}
          <div
            ref={wheelRef}
            className="relative"
            style={{
              width: cardW,
              height: cardH,
              transformStyle: 'preserve-3d',
              willChange: 'transform',
              position: 'absolute',
              left: '50%',
              top: '50%',
              marginLeft: -cardW / 2,
              marginTop: -cardH / 2,
            }}
          >
            {cards.map((card, idx) => (
              <Card
                key={card.key}
                src={card.src}
                transform={card.transform}
                cardW={cardW}
                cardH={cardH}
                innerRef={(el) => {
                  cardRefs.current[idx] = el;
                }}
              />
            ))}
          </div>
        </div>

        {/* Ambient guidelines help text (fades out on interaction) */}
        <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 or Left/Right arrows to rotate</span>
          <span className="text-zinc-300 dark:text-zinc-800">•</span>
          <span>Space to toggle play</span>
        </div>
      </div>
    );
  }
);

ThreeDCarousel.displayName = 'ThreeDCarousel';

export { ThreeDCarousel };

≡Props

Command Palette

Search for a command to run...