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 rotateSpace 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...