shadimage
uniquel
componentarch-gallerysource: ok · hit

Arch Gallery

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

npx shadcn add "https://uniquel.io/r/arch-gallery.json"
free

Where it goes

gallery

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files1 · source cached 10/14/2026, 1:52:31 PM

components/gallery/ArchGallery.tsx
'use client';

import { useState, type CSSProperties } from 'react';

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
//
// items         Gallery entries — image src and optional alt
// cardWidth     Card width in px
// cardHeight    Card height in px
// cornerRadius  Border radius in px
// className     Styles for the outer shell
//
type GalleryItem = {
  image: { src: string; alt?: string };
};

type ArchGalleryProps = {
  items?: GalleryItem[];
  cardWidth?: number;
  cardHeight?: number;
  cornerRadius?: number;
  className?: string;
};

// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const DEFAULT_ITEMS: GalleryItem[] = [
  {
    image: {
      src: 'https://assets.uniquel.io/components/stock/photo-1497366216548-37526070297c.jpg',
      alt: 'Minimal office workspace',
    },
  },
  {
    image: {
      src: 'https://assets.uniquel.io/components/stock/photo-1487958449943-2429e8be8625.jpg',
      alt: 'Modern concrete building',
    },
  },
  {
    image: {
      src: 'https://assets.uniquel.io/components/stock/photo-1503387762-592deb58ef4e.jpg',
      alt: 'Architectural interior detail',
    },
  },
  {
    image: {
      src: 'https://assets.uniquel.io/components/stock/photo-1600607687939-ce8a6c25118c.jpg',
      alt: 'Bright living room interior',
    },
  },
  {
    image: {
      src: 'https://assets.uniquel.io/components/stock/photo-1486325212027-8081e485255e.jpg',
      alt: 'Curved glass tower',
    },
  },
  {
    image: {
      src: 'https://assets.uniquel.io/components/stock/photo-1618221195710-dd6b41faaea6.jpg',
      alt: 'Styled interior corner',
    },
  },
  {
    image: {
      src: 'https://assets.uniquel.io/components/stock/photo-1616486338812-3dadae4b4ace.jpg',
      alt: 'Warm living space',
    },
  },
];

const ROTATE_STEP = 6;
const Y_STEP = 18;
const OVERLAP = 0.58;
const HOVER_SCALE = 1.08;
const HOVER_LIFT = 16;

export default function ArchGallery({
  items = DEFAULT_ITEMS,
  cardWidth = 180,
  cardHeight = 240,
  cornerRadius = 18,
  className = '',
}: ArchGalleryProps) {
  const deck = items.length ? items : DEFAULT_ITEMS;
  const total = deck.length;
  const mid = (total - 1) / 2;
  const [hovered, setHovered] = useState<number | null>(null);

  const stageWidth = cardWidth + Math.abs(mid) * 2 * cardWidth * OVERLAP + cardWidth * 0.2;
  const stageHeight = cardHeight + Math.abs(mid) * Y_STEP + 48;

  return (
    <div
      className={['flex w-full items-center justify-center py-10', className]
        .filter(Boolean)
        .join(' ')}
      role='group'
      aria-label='Image gallery'
    >
      <div
        className='relative'
        style={{ width: stageWidth, height: stageHeight }}
      >
        {deck.map((entry, index) => {
          const offset = index - mid;
          const rotate = offset * ROTATE_STEP;
          const translateY = Math.abs(offset) * Y_STEP;
          const translateX = offset * cardWidth * OVERLAP;
          const baseZ = total - Math.abs(offset);
          const isHovered = hovered === index;

          const cardStyle: CSSProperties = {
            position: 'absolute',
            left: '50%',
            top: '50%',
            width: cardWidth,
            height: cardHeight,
            marginLeft: -cardWidth / 2,
            marginTop: -cardHeight / 2,
            borderRadius: cornerRadius,
            overflow: 'hidden',
            transformOrigin: 'center center',
            transform: isHovered
              ? `translate(${translateX}px, ${translateY - HOVER_LIFT}px) rotate(0deg) scale(${HOVER_SCALE})`
              : `translate(${translateX}px, ${translateY}px) rotate(${rotate}deg) scale(1)`,
            zIndex: isHovered ? total + 1 : baseZ,
            transition:
              'transform 280ms cubic-bezier(0.22, 1, 0.36, 1), z-index 0ms',
            boxShadow:
              '0 12px 28px rgba(0,0,0,0.14), 0 2px 8px rgba(0,0,0,0.08)',
            cursor: 'pointer',
            backgroundColor: '#f3f4f6',
          };

          return (
            <div
              key={`${entry.image.src}-${index}`}
              style={cardStyle}
              onMouseEnter={() => setHovered(index)}
              onMouseLeave={() => setHovered(null)}
              onFocus={() => setHovered(index)}
              onBlur={() => setHovered(null)}
              tabIndex={0}
              aria-label={entry.image.alt || `Photo ${index + 1}`}
            >
              <img
                src={entry.image.src}
                alt={entry.image.alt || ''}
                draggable={false}
                className='pointer-events-none absolute inset-0 h-full w-full select-none object-cover'
              />
            </div>
          );
        })}
      </div>
    </div>
  );
}

More from uniquel

Browse all components in this library →