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.
Installation
CLI (Recommended)
npx shadcn@latest add https://satisui.xyz/r/circular-gallery.jsonManual
- Install the required dependencies:
npm install gsap @gsap/react
# or
yarn add gsap @gsap/react
# or
pnpm add gsap @gsap/react- 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
| Prop | Type | Default | Description |
|---|---|---|---|
items | GalleryItem[] | Required | Array of objects containing src, alt, optional key, and onClick. |
radius | number | 700 | Base radius of the 3D cylinder in pixels for desktop screens. |
mobileRadius | number | radius / 2 | Radius for mobile screens (768px). |
scrollDistance | number | 2000 | Total scroll height in pixels. Determines rotation speed relative to scroll. |
rotationTotal | number | -360 | Total rotation in degrees that occurs over the scrollDistance. |
itemWidth | number | 256 | Width of each gallery item in pixels. |
itemHeight | number | 384 | Height of each gallery item in pixels. |
perspective | number | 1000 | CSS perspective value for the 3D container. |
ariaLabel | string | '3D Circular Gallery' | Accessible label for the gallery list. |
Typography Reveal
A component that reveals text content unit-by-unit as it scrolls into view, using GSAP for a variety of animation effects.
Pinned Card Fan
A layout component that pins to the viewport and fans its children out in an arc as the user scrolls. Falls back to a vertical stack on mobile or if reduced motion is enabled.








