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

3D Photo Carousel

A responsive 3D cylindrical image carousel built with Framer Motion. Responds to drag rotation, handles image modal expansions, and supports custom spacing, border-radius, and keyboard interactions.

3dcarouselgalleryorbitframer-motiondraginteractive

Preview

gallery_item_0
gallery_item_1
gallery_item_2
gallery_item_3
gallery_item_4
gallery_item_5
gallery_item_6
gallery_item_7
gallery_item_8
gallery_item_9
gallery_item_10
gallery_item_11
Adjust 3D Photo Carousel Props
Spacing (Column Distance)1
Border Radius12px
Cylinder Width1800px
Auto Rotation Speed0
Hide Close UI Hint
* Click a card to expand. Click background/press ESC to close. Focus container & press Left/Right arrow keys to spin.

🖲Usage

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

export default function ThreeDPhotoCarouselDemo() {
  return (
    <div className="w-full border border-zinc-200 dark:border-zinc-800 rounded-xl overflow-hidden bg-zinc-950/5 dark:bg-zinc-950/40">
      <ThreeDPhotoCarousel height="500px" />
    </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-photo-carousel.tsx inside it.

2

Install dependencies

pnpm add framer-motion
3

Required helper files

Ensure your project has the following helper files configured:

  • lib/utils

<>Source Code

three-d-photo-carousel.tsx
'use client';

import * as React from 'react';
import {
  AnimatePresence,
  motion,
  useAnimation,
  useMotionValue,
  useTransform,
  MotionValue,
} from 'framer-motion';
import { cn } from '@/lib/utils';

// Isomorphic Layout Effect
const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;

// Media Query Hook for responsive design
function useMediaQuery(query: string, defaultValue = false): boolean {
  const [matches, setMatches] = React.useState(defaultValue);

  useIsomorphicLayoutEffect(() => {
    const media = window.matchMedia(query);
    setMatches(media.matches);

    const listener = () => setMatches(media.matches);
    media.addEventListener('change', listener);
    return () => media.removeEventListener('change', listener);
  }, [query]);

  return matches;
}

export interface ThreeDPhotoCarouselProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  | 'onDrag'
  | 'onDragStart'
  | 'onDragEnd'
  | 'onAnimationStart'
  | 'onAnimationEnd'
  | 'onAnimationIteration'
  | 'onTransitionEnd'
> {
  /** Array of image URLs. If omitted, uses curated aesthetic anime/cyberpunk images. */
  images?: string[];
  /** Cylinder width multiplier scale factor (default: 1.0). Controls column distance from center. */
  spacing?: number;
  /** Border radius of the cards (default: "12px"). Supports any CSS unit (px, rem, %). */
  borderRadius?: string;
  /** Cylinder width at full desktop viewport (default: 1800) */
  cylinderWidth?: number;
  /** Height of the carousel viewport (default: "500px") */
  height?: string | number;
  /** Auto rotation speed (degrees per frame, e.g. 0.05). Use 0 to disable (default: 0). */
  autoRotationSpeed?: number;
  /** Hide the overlay caption UI in the enlarged modal (default: false) */
  hideOverlayUI?: boolean;
}

const DEFAULT_AESTHETIC_IMAGES = [
  'https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?auto=format&fit=crop&q=80&w=600&h=800', // Cinematic anime cyberpunk
  'https://images.unsplash.com/photo-1578632767115-351597cf2477?auto=format&fit=crop&q=80&w=600&h=800', // Cozy art room scene
  'https://images.unsplash.com/photo-1541562232579-512a21360020?auto=format&fit=crop&q=80&w=600&h=800', // Japan street shrine vibes
  'https://images.unsplash.com/photo-1503899036084-c55cdd92da26?auto=format&fit=crop&q=80&w=600&h=800', // Tokyo neon cityscape
  'https://images.unsplash.com/photo-1528164344705-47542687000d?auto=format&fit=crop&q=80&w=600&h=800', // Japan Torii gate night
  'https://images.unsplash.com/photo-1518709268805-4e9042af9f23?auto=format&fit=crop&q=80&w=600&h=800', // Misty castle fantasy landscape
  'https://images.unsplash.com/photo-1509198397868-475647b2a1e5?auto=format&fit=crop&q=80&w=600&h=800', // Retro vaporwave game room
  'https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?auto=format&fit=crop&q=80&w=600&h=800', // Vaporwave art illustration
  'https://images.unsplash.com/photo-1516979187457-637abb4f9353?auto=format&fit=crop&q=80&w=600&h=800', // Cozy anime library layout
  'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=600&h=800', // Cyberpunk neon alleyway
  'https://images.unsplash.com/photo-1534447677768-be436bb09401?auto=format&fit=crop&q=80&w=600&h=800', // Starry galaxy space sky
  'https://images.unsplash.com/photo-1580477667995-2b94f01c9516?auto=format&fit=crop&q=80&w=600&h=800', // Cherry blossom illustration
];

const transition = {
  duration: 0.15,
  ease: [0.32, 0.72, 0, 1] as const,
};

const transitionOverlay = {
  duration: 0.5,
  ease: [0.32, 0.72, 0, 1] as const,
};

type CarouselControls = ReturnType<typeof useAnimation>;

const Carousel = React.memo(
  ({
    handleClick,
    controls,
    cards,
    isCarouselActive,
    spacing,
    borderRadius,
    cylinderWidthDesktop,
    autoRotationSpeed,
    rotation,
  }: {
    handleClick: (imgUrl: string, index: number) => void;
    controls: CarouselControls;
    cards: string[];
    isCarouselActive: boolean;
    spacing: number;
    borderRadius: string;
    cylinderWidthDesktop: number;
    autoRotationSpeed: number;
    rotation: MotionValue<number>;
  }) => {
    const isScreenSizeSm = useMediaQuery('(max-width: 640px)');
    const cylinderWidth = isScreenSizeSm
      ? cylinderWidthDesktop * 0.6
      : cylinderWidthDesktop;
    const faceCount = cards.length;
    const faceWidth = cylinderWidth / faceCount;
    const radius = (cylinderWidth / (2 * Math.PI)) * spacing;

    const isDragging = React.useRef(false);

    const transform = useTransform(
      rotation,
      (value: number) => `rotate3d(0, 1, 0, ${value}deg)`
    );

    // Auto-rotation engine
    React.useEffect(() => {
      if (autoRotationSpeed === 0 || !isCarouselActive) return;

      let lastTime = performance.now();
      let frameId: number;

      const tick = (time: number) => {
        const delta = time - lastTime;
        lastTime = time;

        if (!isDragging.current) {
          // Normalize rotation based on delta time to keep it smooth across devices
          rotation.set(rotation.get() - autoRotationSpeed * (delta * 0.06));
        }

        frameId = requestAnimationFrame(tick);
      };

      frameId = requestAnimationFrame(tick);
      return () => cancelAnimationFrame(frameId);
    }, [autoRotationSpeed, isCarouselActive, rotation]);

    return (
      <div
        className="flex h-full items-center justify-center bg-transparent"
        style={{
          perspective: '1000px',
          transformStyle: 'preserve-3d',
          willChange: 'transform',
        }}
      >
        <motion.div
          drag={isCarouselActive ? 'x' : false}
          className="relative flex h-full origin-center cursor-grab justify-center active:cursor-grabbing shrink-0"
          style={{
            transform,
            rotateY: rotation,
            width: cylinderWidth,
            transformStyle: 'preserve-3d',
          }}
          onDragStart={() => {
            isDragging.current = true;
          }}
          onDrag={(_, info) => {
            if (isCarouselActive) {
              rotation.set(rotation.get() + info.offset.x * 0.05);
            }
          }}
          onDragEnd={(_, info) => {
            isDragging.current = false;
            if (isCarouselActive) {
              controls.start({
                rotateY: rotation.get() + info.velocity.x * 0.05,
                transition: {
                  type: 'spring' as const,
                  stiffness: 100,
                  damping: 30,
                  mass: 0.1,
                },
              });
            }
          }}
          animate={controls}
        >
          {cards.map((imgUrl, i) => (
            <motion.div
              key={`key-${imgUrl}-${i}`}
              className="absolute flex h-full origin-center items-center justify-center"
              style={{
                width: `${faceWidth}px`,
                borderRadius,
                transform: `rotateY(${
                  i * (360 / faceCount)
                }deg) translateZ(${radius}px)`,
              }}
              onClick={() => handleClick(imgUrl, i)}
            >
              <motion.img
                src={imgUrl}
                alt={`gallery_item_${i}`}
                layoutId={`img-${imgUrl}`}
                className="pointer-events-none w-full object-cover aspect-square shadow-lg select-none"
                style={{ borderRadius }}
                initial={{ filter: 'blur(4px)' }}
                layout="position"
                animate={{ filter: 'blur(0px)' }}
                transition={transition}
              />
            </motion.div>
          ))}
        </motion.div>
      </div>
    );
  }
);

Carousel.displayName = 'Carousel';

export function ThreeDPhotoCarousel({
  images = DEFAULT_AESTHETIC_IMAGES,
  spacing = 1.0,
  borderRadius = '12px',
  cylinderWidth = 1800,
  height = '500px',
  autoRotationSpeed = 0,
  hideOverlayUI = false,
  className,
  ...props
}: ThreeDPhotoCarouselProps) {
  const [activeImg, setActiveImg] = React.useState<string | null>(null);
  const [isCarouselActive, setIsCarouselActive] = React.useState(true);
  const controls = useAnimation();
  const rotation = useMotionValue(0);

  const containerRef = React.useRef<HTMLDivElement>(null);

  const handleClick = (imgUrl: string) => {
    setActiveImg(imgUrl);
    setIsCarouselActive(false);
    controls.stop();
  };

  const handleClose = React.useCallback(() => {
    setActiveImg(null);
    setIsCarouselActive(true);
  }, []);

  // Keyboard navigation support
  React.useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (activeImg) {
        if (e.key === 'Escape') {
          handleClose();
        }
      } else {
        const container = containerRef.current;
        const isActive =
          document.activeElement === container ||
          container?.contains(document.activeElement);

        if (isActive) {
          if (e.key === 'ArrowLeft') {
            e.preventDefault();
            rotation.set(rotation.get() + 15);
          } else if (e.key === 'ArrowRight') {
            e.preventDefault();
            rotation.set(rotation.get() - 15);
          }
        }
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [activeImg, handleClose, rotation]);

  return (
    <motion.div
      ref={containerRef}
      tabIndex={0}
      layout
      className={cn(
        'relative focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-700 rounded-2xl select-none bg-zinc-950/5 dark:bg-zinc-950/40',
        className
      )}
      style={{ height, ...props.style }}
      {...props}
    >
      <AnimatePresence mode="sync">
        {activeImg && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            layoutId={`img-container-${activeImg}`}
            layout="position"
            onClick={handleClose}
            className="fixed inset-0 bg-black/60 backdrop-blur-xs flex flex-col items-center justify-center z-50 p-4 md:p-16 lg:p-24"
            style={{ willChange: 'opacity' }}
            transition={transitionOverlay}
          >
            <div className="relative max-w-full max-h-full flex items-center justify-center p-2">
              <motion.img
                layoutId={`img-${activeImg}`}
                src={activeImg}
                className="max-w-[90vw] max-h-[75vh] object-contain rounded-lg shadow-2xl border border-white/10"
                initial={{ scale: 0.8 }}
                animate={{ scale: 1 }}
                transition={{
                  delay: 0.1,
                  duration: 0.4,
                  ease: [0.25, 0.1, 0.25, 1],
                }}
                style={{
                  willChange: 'transform',
                }}
              />
              {!hideOverlayUI && (
                <div className="absolute -bottom-12 left-1/2 -translate-x-1/2 z-50 text-white/70 text-xs tracking-wider bg-black/50 backdrop-blur-md px-3.5 py-1.5 rounded-full border border-white/10 pointer-events-none uppercase">
                  Press ESC or click outside to dismiss
                </div>
              )}
            </div>
          </motion.div>
        )}
      </AnimatePresence>

      <div className="relative w-full h-full overflow-hidden">
        <Carousel
          handleClick={handleClick}
          controls={controls}
          cards={images}
          isCarouselActive={isCarouselActive}
          spacing={spacing}
          borderRadius={borderRadius}
          cylinderWidthDesktop={cylinderWidth}
          autoRotationSpeed={autoRotationSpeed}
          rotation={rotation}
        />

        {/* Shadow/Reflect ambient depth enhancer */}
        <div className="absolute bottom-0 left-0 right-0 h-16 pointer-events-none bg-gradient-to-t from-zinc-100/10 via-zinc-100/5 to-transparent dark:from-zinc-950/20 dark:via-zinc-950/5" />
      </div>
    </motion.div>
  );
}

Props

Command Palette

Search for a command to run...