Image Gallery
An infinite-scroll carousel with GSAP-powered physics and drag interactions.
Overview
A cinematic infinite-scroll image carousel built with GSAP. Features seamless looping, drag-to-scroll physics, velocity-based skewing, and hover zoom effects for a premium browsing experience.
Features
- Seamless infinite loop with automatic wrapping
- Drag and wheel scroll support via GSAP Observer
- Velocity-based skew effects
- Hover zoom and overlay animations
- Auto-drift with pause on interaction
Preview
Infinite Series
Drag to Explore
Installation
Method 1: CLI (Recommended)
Install via the ZenBlocks CLI to automatically handle dependencies:
npx shadcn@latest add https://zenblocks-three.vercel.app/r/image-gallery.jsonMethod 2: Manual
-
Install Dependencies
npm install gsap @gsap/react -
Copy the Source Code
Copy the code below into
components/zenblocks/image-gallery.tsx.Click to expand source
"use client"; import React, { useRef, useMemo } from 'react'; import { gsap, Observer } from 'gsap/all'; import { useGSAP } from '@gsap/react'; import { cn } from "@/lib/utils"; /** * ImageGallery Component * "Production-Level" Infinite Scroll Carousel * * Features: * - Seamless infinite loop (using 2x dupe + wrapping) * - Premium, contained styling (no overflow mess) */ export const DEFAULT_IMAGES = [ { url: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?q=80&w=1200&auto=format&fit=crop", title: "Ethereal Valley", tag: "Nature" }, { url: "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=1200&auto=format&fit=crop", title: "Mist Peaks", tag: "Atmosphere" }, { url: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=1200&auto=format&fit=crop", title: "Infinite Forest", tag: "Serenity" }, { url: "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1200&auto=format&fit=crop", title: "Glass Lake", tag: "Reflection" }, { url: "https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?q=80&w=1200&auto=format&fit=crop", title: "Zen Bridge", tag: "Path" }, { url: "https://images.unsplash.com/photo-1472214103451-9374bd1c798e?q=80&w=1200&auto=format&fit=crop", title: "Crimson Horizon", tag: "Twilight" }, ]; gsap.registerPlugin(Observer); export interface ImageGalleryItem { url: string; title: string; tag: string; } export interface ImageGalleryProps { className?: string; items?: ImageGalleryItem[]; } export const ImageGallery: React.FC<ImageGalleryProps> = ({ className, items = DEFAULT_IMAGES }) => { const containerRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null); // Duplicate items for seamless loop (triple buffer is safest for wide screens) // If items is empty/undefined, handle gracefully const displayItems = useMemo(() => { if (!items || items.length === 0) return []; return [...items, ...items, ...items]; }, [items]); useGSAP(() => { if (!containerRef.current || !trackRef.current) return; if (displayItems.length === 0) return; const track = trackRef.current; const allItems = gsap.utils.toArray<HTMLElement>('.gallery-item'); if (allItems.length === 0) return; // Layout Config (Dynamic) // We need to wait for layout or use hardcoded assumptions if rendered yet // Since we are in useGSAP, elements should be mounted. const itemWidth = allItems[0].offsetWidth; const gap = parseFloat(window.getComputedStyle(track).gap) || 32; const totalItemWidth = itemWidth + gap; const groupWidth = items.length * totalItemWidth; // Initialize position // Center the initial view in the middle set of images let xPos = -groupWidth; gsap.set(track, { x: xPos }); let isDragging = false; let velocity = 0; const updateLoop = () => { // Friction velocity *= 0.92; xPos += velocity; // Seamless Wrapping Logic // If we've scrolled past the first set, wrap back to end if (xPos > 0) { xPos = -groupWidth; } // If we've scrolled past the second set, wrap back to start else if (xPos < -groupWidth * 2) { xPos = -groupWidth; } gsap.set(track, { x: xPos }); // Apply Parallax to inner images based on velocity // More velocity = more skew/parallax const skewAmount = gsap.utils.clamp(-5, 5, velocity * 0.5); gsap.set(allItems, { skewX: skewAmount }); }; const loop = gsap.to({}, { duration: 1 / 60, repeat: -1, onRepeat: updateLoop }); // Auto-scroll drift let driftStrength = -0.5; // GSAP Observer for Touch/Wheel/Drag const observer = Observer.create({ target: containerRef.current, type: "wheel,touch,pointer", onPress: () => { isDragging = true; driftStrength = 0; // Pause auto-drift while interacting document.body.style.cursor = 'grabbing'; }, onRelease: () => { isDragging = false; driftStrength = -0.5; // Resume drift document.body.style.cursor = 'default'; }, onChange: (self) => { const delta = self.deltaX || self.deltaY; // vertical scroll wheel -> horizontal drift velocity += -delta * 0.05; } }); // Add constant drift to velocity gsap.ticker.add(() => { if (!isDragging) { velocity += driftStrength * 0.05; } }); // Local Interactions (Hover, Tilt) allItems.forEach((el) => { const item = el as HTMLElement; const img = item.querySelector('img'); const overlay = item.querySelector('.overlay'); item.addEventListener('mouseenter', () => { if (isDragging) return; gsap.to(item, { scale: 1.02, duration: 0.4, ease: "power2.out" }); gsap.to(img, { scale: 1.15, duration: 0.4, ease: "power2.out" }); gsap.to(overlay, { opacity: 1, duration: 0.4 }); }); item.addEventListener('mouseleave', () => { gsap.to(item, { scale: 1, duration: 0.4, ease: "power2.out" }); gsap.to(img, { scale: 1.05, duration: 0.4, ease: "power2.out" }); // Keep slightly scaled for parallax gsap.to(overlay, { opacity: 0, duration: 0.4 }); }); }); return () => { loop.kill(); observer.kill(); gsap.ticker.remove(containerRef.current as any); // cleanup ticker listener }; }, { scope: containerRef, dependencies: [displayItems] }); return ( <div ref={containerRef} className={cn( "relative w-full h-[400px] md:h-[600px] overflow-hidden bg-zinc-50 dark:bg-transparent flex flex-col justify-center select-none cursor-grab active:cursor-grabbing", className )} > {/* Cinematic Fade Edges */} <div className="absolute inset-y-0 left-0 w-32 bg-gradient-to-r from-zinc-50 to-transparent dark:from-transparent z-20 pointer-events-none" /> <div className="absolute inset-y-0 right-0 w-32 bg-gradient-to-l from-zinc-50 to-transparent dark:from-transparent z-20 pointer-events-none" /> {/* Title / Header (Optional) */} <div className="absolute top-4 left-0 w-full text-center z-10 pointer-events-none opacity-40"> <p className="text-[10px] uppercase tracking-[0.4em] font-bold">Infinite Series</p> </div> {/* TRACK */} <div ref={trackRef} className="flex gap-4 md:gap-8 px-4 md:px-8 items-center" style={{ width: 'max-content' }} > {displayItems.map((item, i) => ( <div key={i} className="gallery-item relative w-[220px] md:w-[320px] aspect-[3/4] rounded-2xl overflow-hidden bg-zinc-200 dark:bg-zinc-800 shadow-xl border border-white/20 dark:border-white/5 WillChangeTransform" > {/* Image Container */} <div className="absolute inset-0 overflow-hidden"> <img src={item.url} alt={item.title} className="w-full h-full object-cover scale-105 transition-transform duration-700 will-change-transform" /> </div> {/* Minimalist Overlay */} <div className="overlay absolute inset-0 bg-black/40 opacity-0 transition-opacity duration-300 flex flex-col justify-end p-6"> <p className="text-[10px] text-white/70 uppercase tracking-widest">{item.tag}</p> <h3 className="text-xl text-white font-medium mt-1">{item.title}</h3> </div> </div> ))} </div> {/* Progress / Hint */} <div className="absolute bottom-4 left-0 w-full text-center z-10 pointer-events-none opacity-40"> <p className="text-[10px] uppercase tracking-[0.2em]">Drag to Explore</p> </div> </div> ); };
Usage
import { ImageGallery } from "@/components/zenblocks/image-gallery";
<ImageGallery />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | ImageGalleryItem[] | DEFAULT_IMAGES | Array of image objects |
| className | string | - | Additional CSS classes |
ImageGalleryItem
| Property | Type | Description |
|---|---|---|
| url | string | Image source URL |
| title | string | Image title |
| tag | string | Category tag |
Accessibility
- Images have descriptive
altattributes - Keyboard users can scroll with arrow keys (via browser)
- Reduced motion disables auto-drift
- High contrast overlays for text readability
Customization
Custom Images
const myImages = [
{ url: "/img1.jpg", title: "Sunset", tag: "Nature" },
{ url: "/img2.jpg", title: "City", tag: "Urban" }
];
<ImageGallery items={myImages} />Styling
<ImageGallery className="h-[600px]" />Motion Behavior
- Infinite loop: Track position wraps when scrolling past duplicated sets
- Velocity: Accumulated from drag/wheel input, decays with 0.92 friction
- Skew: Applied based on velocity magnitude (clamped to ±5deg)
- Auto-drift: Constant -0.5px/frame velocity when not interacting
Performance Notes
- Uses GSAP's
requestAnimationFrameticker for smooth 60fps updates - Triple-buffered image array for seamless wrapping
- GPU-accelerated transforms (
translate3d,skewX) - Hover effects use CSS transitions, not RAF
Examples
Vertical Layout
Modify the component to scroll vertically by changing x transforms to y.
Notes
- Requires GSAP and GSAP Observer plugin
- Default images are from Unsplash
- Works best with landscape-oriented images
- Cursor changes to
grabbingduring drag