Wheels

Circular Gallery

A 3D circular gallery component that rotates elements around a central Y-axis based on the user's vertical scroll position.

Default Landscape

The standard configuration is perfect for photography portfolios or travel diaries. This example uses standard landscape dimensions (4:3 or 3:2 aspect ratios) and a balanced radius to create a natural, immersive viewing arc.

High-Density Square

A minimal, high-density layout suitable for digital assets, NFTs, or product catalogs. By reducing the item dimensions to a square (200px x 200px) and increasing the radius, this configuration fits more items into the viewport while maintaining a clean "space-like" aesthetic.

Cinematic Posters

Designed for vertical media like movie posters, book covers, or character cards. This example increases the itemHeight to 500px and adjusts the perspective to 1500px for a flatter, more commanding presence that mimics a theatrical display.

Cinematic Collection

Installation

npx shadcn@latest add https://satisui.xyz/r/circular-gallery.json

Manual

  1. Install the required dependencies:
npm install gsap @gsap/react
# or
yarn add gsap @gsap/react
# or
pnpm add gsap @gsap/react
  1. Copy and paste the following code into components/satisui/circular-gallery.tsx:
'use client';

import React, {
  useRef,
  forwardRef,
  useImperativeHandle,
  CSSProperties,
} from 'react';
import Image from 'next/image';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

if (typeof window !== 'undefined') {
  gsap.registerPlugin(ScrollTrigger);
}

export interface GalleryItem {
  src: string;
  alt: string;
  key?: string;
  onClick?: () => void;
}

/**
 * Props for the CircularGallery component.
 */
interface CircularGalleryProps extends React.HTMLAttributes<HTMLDivElement> {
  items: GalleryItem[];
  /** Base radius of the 3D cylinder in pixels for desktop screens. Defaults to 700. */
  radius?: number;
  /** Radius for mobile screens (<768px). Defaults to half of `radius`. */
  mobileRadius?: number;
  /** Total scroll height in pixels. Determines the speed of rotation relative to scroll depth. */
  scrollDistance?: number;
  /** Total rotation in degrees that occurs over the `scrollDistance`. Defaults to -360. */
  rotationTotal?: number;
  /** Width of each gallery item in pixels. */
  itemWidth?: number;
  /** Height of each gallery item in pixels. */
  itemHeight?: number;
  /** CSS perspective value for the 3D container. */
  perspective?: number;
  ariaLabel?: string;
}

interface CustomCSSProperties extends CSSProperties {
  '--radius-desktop'?: string;
  '--radius-mobile'?: string;
}

/**
 * A 3D circular gallery component that rotates elements around a central Y-axis
 * based on the user's vertical scroll position.
 *
 * Utilizes GSAP ScrollTrigger for animation and standard Shadcn UI/Tailwind
 * semantic classes for theming.
 */
const CircularGallery = forwardRef<HTMLDivElement, CircularGalleryProps>(
  (
    {
      items,
      radius = 700,
      mobileRadius,
      scrollDistance = 2000,
      rotationTotal = -360,
      itemWidth = 256,
      itemHeight = 384,
      perspective = 1000,
      ariaLabel = '3D Circular Gallery',
      className,
      ...rest
    },
    ref
  ) => {
    const containerRef = useRef<HTMLDivElement>(null);
    const carouselRef = useRef<HTMLUListElement>(null);

    useImperativeHandle(ref, () => containerRef.current!);

    const actualMobileRadius = mobileRadius ?? radius * 0.5;

    useGSAP(
      () => {
        if (!carouselRef.current || !containerRef.current) return;

        const mm = gsap.matchMedia();

        const setupScroll = (startPosition: string) => {
          const tl = gsap.timeline({
            scrollTrigger: {
              trigger: containerRef.current,
              pin: true,
              start: startPosition,
              end: `+=${scrollDistance}`,
              scrub: 1,
              invalidateOnRefresh: true,
              fastScrollEnd: true,
            },
          });

          tl.to(carouselRef.current, {
            rotationY: rotationTotal,
            ease: 'none',
          });
        };

        mm.add('(min-width: 768px)', () => setupScroll('top top'));
        mm.add('(max-width: 767px)', () => setupScroll('center center'));
        mm.add('(prefers-reduced-motion: reduce)', () => {
          if (carouselRef.current)
            gsap.set(carouselRef.current, { rotationY: 0 });
        });
      },
      { scope: containerRef, dependencies: [scrollDistance, rotationTotal] }
    );

    if (!items || items.length === 0) return null;

    const angleIncrement = 360 / items.length;

    return (
      <div
        ref={containerRef}
        className={cn(
          'relative flex min-h-screen w-full flex-col items-center justify-center overflow-hidden bg-background py-12 z-10',
          className
        )}
        {...rest}
      >
        <div
          className='relative flex h-full w-full items-center justify-center'
          style={{ perspective: `${perspective}px` }}
        >
          <ul
            ref={carouselRef}
            className={cn(
              'group relative flex h-0 w-0 items-center justify-center will-change-transform',
              '[--radius:var(--radius-mobile)] md:[--radius:var(--radius-desktop)]'
            )}
            style={
              {
                transformStyle: 'preserve-3d',
                '--radius-desktop': `${radius}px`,
                '--radius-mobile': `${actualMobileRadius}px`,
              } as CustomCSSProperties
            }
            role='list'
            aria-label={ariaLabel}
          >
            {items.map((item, index) => {
              const uniqueKey = item.key || `${item.src}-${index}`;
              const angle = angleIncrement * index;
              const isInteractive = !!item.onClick;

              return (
                <li
                  key={uniqueKey}
                  role={isInteractive ? 'button' : 'listitem'}
                  tabIndex={isInteractive ? 0 : -1}
                  onClick={item.onClick}
                  onKeyDown={(e) => {
                    if (isInteractive && (e.key === 'Enter' || e.key === ' ')) {
                      e.preventDefault();
                      item.onClick?.();
                    }
                  }}
                  className={cn(
                    'absolute overflow-hidden rounded-2xl border border-border bg-card shadow-sm',
                    'transition-all duration-700 ease-[cubic-bezier(0.25,0.4,0.25,1)]',
                    isInteractive ? 'cursor-pointer' : 'cursor-default',
                    // Group hover logic
                    'group-hover:opacity-25 group-hover:blur-[3px] group-hover:grayscale',
                    // Active logic
                    'hover:!opacity-100 hover:!blur-none hover:!grayscale-0 hover:border-primary hover:ring-2 hover:ring-primary/20',
                    'focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring focus-visible:ring-offset-2'
                  )}
                  style={{
                    width: `${itemWidth}px`,
                    height: `${itemHeight}px`,
                    marginLeft: `-${itemWidth / 2}px`,
                    marginTop: `-${itemHeight / 2}px`,
                    transform: `rotateY(${angle}deg) translateZ(var(--radius)) rotateY(-180deg)`,
                    backfaceVisibility: 'hidden',
                  }}
                >
                  <div className='relative h-full w-full bg-muted'>
                    <Image
                      src={item.src}
                      alt={item.alt}
                      fill
                      sizes='(max-width: 768px) 100vw, 30vw'
                      className='object-cover select-none pointer-events-none'
                      priority={index < 4}
                    />
                    {/* Vignette overlay */}
                    <div
                      className='absolute inset-0 bg-foreground/10 transition-colors duration-700 hover:bg-transparent'
                      aria-hidden='true'
                    />
                  </div>
                </li>
              );
            })}
          </ul>
        </div>
      </div>
    );
  }
);

CircularGallery.displayName = 'CircularGallery';

export default CircularGallery;

Usage

The component creates an immersive scrolling experience. Simply pass an array of images, and the gallery will handle the 3D positioning and scroll pinning automatically.

import CircularGallery from '@/components/circular-gallery';

const galleryItems = [
  { src: '/images/photo1.jpg', alt: 'Photo 1' },
  { src: '/images/photo2.jpg', alt: 'Photo 2' },
  { src: '/images/photo3.jpg', alt: 'Photo 3' },
  { src: '/images/photo4.jpg', alt: 'Photo 4' },
  { src: '/images/photo5.jpg', alt: 'Photo 5' },
  { src: '/images/photo6.jpg', alt: 'Photo 6' },
];

export default function GalleryDemo() {
  return (
    <div className='h-[400vh]'>
      <div className='flex h-screen items-center justify-center'>
        <h1 className='text-4xl font-bold'>Scroll Down</h1>
      </div>

      <CircularGallery
        items={galleryItems}
        radius={900}
        itemWidth={300}
        itemHeight={450}
      />

      <div className='flex h-screen items-center justify-center'>
        <h1 className='text-4xl font-bold'>End of Gallery</h1>
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
itemsGalleryItem[]RequiredArray of objects containing src, alt, optional key, and onClick.
radiusnumber700Base radius of the 3D cylinder in pixels for desktop screens.
mobileRadiusnumberradius / 2Radius for mobile screens (768px).
scrollDistancenumber2000Total scroll height in pixels. Determines rotation speed relative to scroll.
rotationTotalnumber-360Total rotation in degrees that occurs over the scrollDistance.
itemWidthnumber256Width of each gallery item in pixels.
itemHeightnumber384Height of each gallery item in pixels.
perspectivenumber1000CSS perspective value for the 3D container.
ariaLabelstring'3D Circular Gallery'Accessible label for the gallery list.