Coverflow Gallery

3D coverflow carousel with keyboard, dot nav, and optional autoplay that pauses on hover.

Add to your project

Pick the way you like to build.

Let your AI assistant add it

Copy this prompt and paste it into the AI tool building your site.

# Integrate this UI component

Add this component to my project.

1. Install any missing dependencies.
2. Make sure Tailwind CSS is set up and scans the new file.
3. Prefer passing props over editing the component source — changing the component itself may break it.
4. Adjust colors, size, and layout via props or wrapper classes to match my project. You know my codebase better than this snippet does.

## Component source

```tsx
'use client';

import {
  useCallback,
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
//
// items         Gallery entries — image src, alt, and optional caption
// cardWidth     Card width in px
// cardHeight    Card height in px
// cornerRadius  Border radius in px
// autoplay      Auto-advance slides (pauses on hover/focus)
// showCaption   Show caption on the focused card
// className     Styles for the outer shell
//
type GalleryItem = {
  image: { src: string; alt?: string };
  caption?: string;
};

type CoverflowGalleryProps = {
  items?: GalleryItem[];
  cardWidth?: number;
  cardHeight?: number;
  cornerRadius?: number;
  autoplay?: boolean;
  showCaption?: boolean;
  className?: string;
};

// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const DEFAULT_ITEMS: GalleryItem[] = [
  {
    image: {
      src: 'https://assets.uniquel.io/components/stock/photo-1497366216548-37526070297c.jpg',
      alt: 'Minimal office workspace',
    },
    caption: 'Studio\nLight\nCalm',
  },
  {
    image: {
      src: 'https://assets.uniquel.io/components/stock/photo-1487958449943-2429e8be8625.jpg',
      alt: 'Modern concrete building',
    },
    caption: 'Form\nConcrete\nLine',
  },
  {
    image: {
      src: 'https://assets.uniquel.io/components/stock/photo-1503387762-592deb58ef4e.jpg',
      alt: 'Architectural interior detail',
    },
    caption: 'Space\nSteel\nFocus',
  },
  {
    image: {
      src: 'https://assets.uniquel.io/components/stock/photo-1600607687939-ce8a6c25118c.jpg',
      alt: 'Bright living room interior',
    },
    caption: 'Home\nWarm\nOpen',
  },
  {
    image: {
      src: 'https://assets.uniquel.io/components/stock/photo-1486325212027-8081e485255e.jpg',
      alt: 'Curved glass tower',
    },
    caption: 'Curve\nMotion\nDepth',
  },
];

const PERSPECTIVE = 1600;
const SCALE_STEP = 0.16;
const MAX_VISIBLE = 2;
const DEPTH = 240;
const MOVE_MS = 600;
const AUTOPLAY_MS = 2500;
const INACTIVE_DIM = 0.4;
const YAW_TILT = 12;
const ROLL_TILT = 8;
const SPREAD = 8;

export default function CoverflowGallery({
  items = DEFAULT_ITEMS,
  cardWidth = 400,
  cardHeight = 400,
  cornerRadius = 12,
  autoplay = false,
  showCaption = true,
  className = '',
}: CoverflowGalleryProps) {
  const deck = items.length ? items : DEFAULT_ITEMS;
  const total = deck.length;

  const [focusedIndex, setFocusedIndex] = useState(0);
  const [isPaused, setIsPaused] = useState(false);
  const inputLockedRef = useRef(false);

  const releaseLockLater = useCallback(() => {
    inputLockedRef.current = true;
    window.setTimeout(() => {
      inputLockedRef.current = false;
    }, Math.max(50, MOVE_MS));
  }, []);

  const advance = useCallback(
    (direction: number) => {
      if (inputLockedRef.current || total < 2) return;
      releaseLockLater();
      setFocusedIndex((current) => (((current + direction) % total) + total) % total);
    },
    [releaseLockLater, total]
  );

  const jumpToIndex = useCallback(
    (index: number) => {
      if (inputLockedRef.current) return;
      releaseLockLater();
      setFocusedIndex(index);
    },
    [releaseLockLater]
  );

  const focusCard = useCallback(
    (index: number) => {
      if (inputLockedRef.current || autoplay) return;
      releaseLockLater();
      setFocusedIndex((current) =>
        index === current ? (current + 1) % total : index
      );
    },
    [autoplay, releaseLockLater, total]
  );

  useEffect(() => {
    setFocusedIndex((current) => Math.max(0, Math.min(total - 1, current)));
  }, [total]);

  useEffect(() => {
    if (!autoplay || isPaused || total < 2) return;
    const id = window.setInterval(() => advance(1), AUTOPLAY_MS);
    return () => window.clearInterval(id);
  }, [advance, autoplay, isPaused, total]);

  const onKeyDown = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      if (event.key === 'ArrowRight') {
        event.preventDefault();
        advance(1);
      } else if (event.key === 'ArrowLeft') {
        event.preventDefault();
        advance(-1);
      }
    },
    [advance]
  );

  const transitionCss = `transform ${MOVE_MS}ms cubic-bezier(0.22, 1, 0.36, 1), opacity ${MOVE_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`;

  const rootStyle: CSSProperties = {
    position: 'relative',
    width: '100%',
    minWidth: 320,
    minHeight: 360,
    display: 'flex',
    flexDirection: 'column',
    alignItems: 'center',
    justifyContent: 'center',
    perspective: `${PERSPECTIVE}px`,
    overflow: 'hidden',
    outline: 'none',
  };

  return (
    <div
      className={['w-full py-8', className].filter(Boolean).join(' ')}
      tabIndex={0}
      role='group'
      aria-roledescription='carousel'
      aria-label='Image gallery'
      onKeyDown={onKeyDown}
      onMouseEnter={() => setIsPaused(true)}
      onMouseLeave={() => setIsPaused(false)}
      onFocus={() => setIsPaused(true)}
      onBlur={() => setIsPaused(false)}
    >
      <div style={rootStyle}>
        <div
          style={{
            position: 'relative',
            width: cardWidth,
            height: cardHeight,
            transformStyle: 'preserve-3d',
          }}
        >
          {deck.map((entry, index) => {
            let offset = index - focusedIndex;
            if (offset > total / 2) offset -= total;
            if (offset < -total / 2) offset += total;

            const distance = Math.abs(offset);
            const isVisible = distance <= MAX_VISIBLE;
            const isFocused = offset === 0;
            const scale = Math.max(0.4, 1 - distance * SCALE_STEP);
            const translateX = offset * SPREAD * 30;
            const translateZ = -distance * DEPTH;
            const rotateY = -offset * YAW_TILT;
            const rotateZ = offset * ROLL_TILT;

            const cardStyle: CSSProperties = {
              position: 'absolute',
              left: '50%',
              top: '50%',
              width: cardWidth,
              height: cardHeight,
              borderRadius: cornerRadius,
              overflow: 'hidden',
              transformStyle: 'preserve-3d',
              transformOrigin: 'center center',
              transform: `translate(-50%, -50%) translateX(${translateX}px) translateZ(${translateZ}px) rotateY(${rotateY}deg) rotateZ(${rotateZ}deg) scale(${scale})`,
              transition: transitionCss,
              opacity: isVisible ? 1 : 0,
              cursor: autoplay || isFocused ? 'default' : 'pointer',
              pointerEvents: isVisible && !autoplay ? 'auto' : 'none',
              backgroundColor: '#171717',
            };

            return (
              <div
                key={`${entry.image.src}-${index}`}
                style={cardStyle}
                onClick={() => focusCard(index)}
                aria-label={entry.caption || entry.image.alt}
                aria-hidden={!isVisible}
              >
                <img
                  src={entry.image.src}
                  alt={entry.image.alt || entry.caption || ''}
                  draggable={false}
                  style={{
                    position: 'absolute',
                    inset: 0,
                    width: '100%',
                    height: '100%',
                    objectFit: 'cover',
                    display: 'block',
                    userSelect: 'none',
                  }}
                />

                {showCaption && entry.caption && isFocused && (
                  <>
                    <div
                      style={{
                        position: 'absolute',
                        inset: 0,
                        background:
                          'linear-gradient(180deg, rgba(0,0,0,0) 35%, rgba(0,0,0,0.72) 100%)',
                        pointerEvents: 'none',
                      }}
                    />
                    <div className='pointer-events-none absolute bottom-0 left-0 p-6 text-white'>
                      <span className='whitespace-pre-line text-2xl font-bold leading-tight tracking-tight drop-shadow-md'>
                        {entry.caption}
                      </span>
                    </div>
                  </>
                )}

                <div
                  style={{
                    position: 'absolute',
                    inset: 0,
                    background: '#000000',
                    opacity: isFocused ? 0 : INACTIVE_DIM,
                    transition: `opacity ${MOVE_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`,
                    pointerEvents: 'none',
                  }}
                />
              </div>
            );
          })}
        </div>
      </div>

      <div className='mt-6 flex items-center justify-center gap-2'>
        {deck.map((entry, index) => (
          <button
            key={`dot-${entry.image.src}-${index}`}
            type='button'
            aria-label={`Go to slide ${index + 1}`}
            onClick={() => jumpToIndex(index)}
            className={[
              'h-2 rounded-full transition-all duration-300',
              index === focusedIndex
                ? 'w-6 bg-gray-900'
                : 'w-2 bg-gray-300 hover:bg-gray-400',
            ].join(' ')}
          />
        ))}
      </div>
    </div>
  );
}

```

Works with your AI tools

ClaudeClaude*
CodexCodex*
LovableLovable
Base44Base44
Figma MakeFigma Make
ReplitReplit*
CursorCursor*
v0v0

* Compatible with most projects that use the default React/Next.js framework.

Built With

ReactTailwind CSS

Props

PropTypeDescription
itemsGalleryItem[]Gallery entries — image src, alt, and optional caption
cardWidthnumberCard width in px
cardHeightnumberCard height in px
cornerRadiusnumberBorder radius in px
autoplaybooleanAuto-advance slides (pauses on hover/focus)
showCaptionbooleanShow the caption on the focused card
classNamestringStyles for the outer shell

More Gallery Components

Minimal office workspace
Modern concrete building
Architectural interior detail
Bright living room interior
Curved glass tower
Styled interior corner
Warm living space

Fanned photo arch with center-forward stacking; hover lifts a card and scales it up.