Scrolling Gallery

A smooth scrolling gallery component with parallax effects and dynamic height calculation. Features GSAP ScrollSmoother, velocity-based skewing, and alternating image positioning for an engaging scroll experience.

React
Tailwind CSS
Gallery
GSAP
ScrollTrigger
Parallax
EmilyRoiinDaisyLanceReneiLindaEmilyRoiinDaisyLanceReneiLindaEmily

Using CLI

npx dimaac add ScrollingGallery

Manual Installation

npm install react @gsap/react

lib/utils.ts

import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

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

lib/useGSAPResize.tsx

import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger, ScrollSmoother } from 'gsap/all';

interface UseGSAPResizeOptions {
  debounceMs?: number;
  refreshScrollTrigger?: boolean;
  refreshScrollSmoother?: boolean;
  onResize?: () => void;
  dependencies?: unknown[];
}

/**
 * Custom hook that handles GSAP resize issues
 * Automatically refreshes ScrollTrigger and ScrollSmoother on window resize
 */
export function useGSAPResize({
  debounceMs = 150,
  refreshScrollTrigger = true,
  refreshScrollSmoother = true,
  onResize,
  dependencies = []
}: UseGSAPResizeOptions = {}) {
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
  const isResizingRef = useRef(false);

  useEffect(() => {
    const handleResize = () => {
      // Clear existing timeout
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }

      isResizingRef.current = true;

      // Debounced resize handler
      timeoutRef.current = setTimeout(() => {
        // Refresh ScrollTrigger instances
        if (refreshScrollTrigger && ScrollTrigger) {
          ScrollTrigger.refresh();
        }

        // Refresh ScrollSmoother if it exists
        if (refreshScrollSmoother && ScrollSmoother) {
          const smoother = ScrollSmoother.get();
          if (smoother) {
            smoother.refresh();
          }
        }

        // Call custom resize handler
        if (onResize) {
          onResize();
        }

        isResizingRef.current = false;
      }, debounceMs);
    };

    // Add event listener
    window.addEventListener('resize', handleResize);

    // Cleanup
    return () => {
      window.removeEventListener('resize', handleResize);
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, [...dependencies]);

  return {
    isResizing: isResizingRef.current
  };
}

/**
 * Enhanced version that also handles orientation change and device pixel ratio changes
 */
export function useGSAPAdvancedResize({
  debounceMs = 150,
  refreshScrollTrigger = true,
  refreshScrollSmoother = true,
  onResize,
  dependencies = []
}: UseGSAPResizeOptions = {}) {
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
  const isResizingRef = useRef(false);

  useEffect(() => {
    let currentDevicePixelRatio = window.devicePixelRatio;

    const handleResize = () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }

      isResizingRef.current = true;

      timeoutRef.current = setTimeout(() => {
        // Check if device pixel ratio changed (zoom)
        if (window.devicePixelRatio !== currentDevicePixelRatio) {
          currentDevicePixelRatio = window.devicePixelRatio;
        }

        // Kill and recreate ScrollSmoother if it exists (more reliable for complex cases)
        if (refreshScrollSmoother && ScrollSmoother) {
          const smoother = ScrollSmoother.get();
          if (smoother) {
            smoother.kill();
            // Small delay to ensure cleanup
            gsap.delayedCall(0.1, () => {
              // Recreate with basic config - user should handle complex configs themselves
              ScrollSmoother.create({
                smooth: 1,
                effects: true
              });
            });
          }
        }

        // Refresh ScrollTrigger after ScrollSmoother recreation
        if (refreshScrollTrigger && ScrollTrigger) {
          gsap.delayedCall(0.15, () => {
            ScrollTrigger.refresh();
          });
        }

        // Call custom resize handler
        if (onResize) {
          gsap.delayedCall(0.2, onResize);
        }

        isResizingRef.current = false;
      }, debounceMs);
    };

    // Multiple event listeners for comprehensive coverage
    window.addEventListener('resize', handleResize);
    window.addEventListener('orientationchange', handleResize);
    
    // Handle zoom changes
    const mediaQuery = window.matchMedia('(resolution: 1dppx)');
    const handleMediaChange = () => handleResize();
    
    if (mediaQuery.addEventListener) {
      mediaQuery.addEventListener('change', handleMediaChange);
    } else {
      // Fallback for older browsers
      mediaQuery.addListener(handleMediaChange);
    }

    return () => {
      window.removeEventListener('resize', handleResize);
      window.removeEventListener('orientationchange', handleResize);
      
      if (mediaQuery.removeEventListener) {
        mediaQuery.removeEventListener('change', handleMediaChange);
      } else {
        mediaQuery.removeListener(handleMediaChange);
      }
      
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, [...dependencies]);

  return {
    isResizing: isResizingRef.current
  };
}

/**
 * Utility function to safely create ScrollSmoother with resize handling
 */
export function createResizeAwareScrollSmoother(config: Record<string, unknown>, resizeOptions?: UseGSAPResizeOptions) {
  // Store the config for recreation
  const scrollSmootherConfig = { ...config };
  
  // Create initial ScrollSmoother
  ScrollSmoother.create(scrollSmootherConfig);
  
  let timeoutRef: NodeJS.Timeout;
  
  const handleResize = () => {
    if (timeoutRef) clearTimeout(timeoutRef);
    
    timeoutRef = setTimeout(() => {
      const existingSmoother = ScrollSmoother.get();
      if (existingSmoother) {
        existingSmoother.kill();
      }
      
      // Recreate with same config
      gsap.delayedCall(0.1, () => {
        ScrollSmoother.create(scrollSmootherConfig);
        ScrollTrigger.refresh();
        
        if (resizeOptions?.onResize) {
          resizeOptions.onResize();
        }
      });
    }, resizeOptions?.debounceMs || 150);
  };
  
  window.addEventListener('resize', handleResize);
  
  // Return cleanup function
  return () => {
    window.removeEventListener('resize', handleResize);
    if (timeoutRef) clearTimeout(timeoutRef);
    const existingSmoother = ScrollSmoother.get();
    if (existingSmoother) existingSmoother.kill();
  };
}

components/ui/ScrollingGallery.tsx

'use client';

import { useState } from 'react';
import { gsap } from 'gsap';
import { useGSAP } from '@gsap/react';
import Image from 'next/image';
import { ScrollSmoother, ScrollTrigger } from 'gsap/all';
import { useGSAPAdvancedResize } from '@/lib/useGSAPResize' 

interface ScrollingGalleryProps {
  images: Array<{
    src: string;
    alt: string;
    speed?: number;
  }>;
  className?: string;
  id?: string;
}

gsap.registerPlugin(ScrollSmoother, ScrollTrigger);

export default function ScrollingGallery({ 
    images, 
    id = "wrapper"
  }: ScrollingGalleryProps) {
    const [dynamicHeight, setDynamicHeight] = useState<number>(0);

    const calculateVisibleHeight = () => {
        const contentElement = document.getElementById(id + "content");
        const wrapperElement = document.getElementById(id);
        const wrapperElementHeight = wrapperElement?.scrollHeight;  
        const transform = contentElement?.style.transform;
        const matrix3dMatch = transform?.match(/matrix3d\(([^)]+)\)/);
        
        if (matrix3dMatch) {
            const matrix3dValue = matrix3dMatch[1];
            const values = matrix3dValue.split(',');
            const yTranslate = parseFloat(values[13].trim());
            const lastImageId = "#" + id + " img:last-child";
            const lastImage = contentElement?.querySelector(lastImageId);
            let lastImgTranslate = 0;
            
            if(lastImage){
              const imgTransform = (lastImage as HTMLElement).style.transform;
              const translateMatch = imgTransform.match(/translate\(([^,]+),\s*([^)]+)\)/);
                if (translateMatch) {
                  const imgYTranslate = parseFloat(translateMatch[2]);
                  lastImgTranslate = lastImgTranslate + imgYTranslate;
                }
            }
            const baseCalculatedHeight = wrapperElementHeight ? wrapperElementHeight + yTranslate : 0;
            lastImgTranslate = lastImgTranslate > 0 ? lastImgTranslate : 0;
            const finalHeight = baseCalculatedHeight + lastImgTranslate;
            console.log(lastImgTranslate, baseCalculatedHeight, finalHeight)
            return finalHeight;
        }
        return 0;
    };


    useGSAPAdvancedResize({
        onResize: () => {
            setTimeout(() => {
                setDynamicHeight(calculateVisibleHeight());
            }, 100);
        },
        dependencies: [id, images.length]
    });

    useGSAP(() => {
        setDynamicHeight(calculateVisibleHeight());
    
        const skewSetter = gsap.quickTo("#" + id + " img", "skewY"),
            clamp = gsap.utils.clamp(-8, 8); 
            
        ScrollSmoother.create({
            smooth: 1,
            speed: 3,
            effects: true,
            onUpdate: self => {
                skewSetter(clamp(self.getVelocity() / -50));
                const newVisibleHeight = calculateVisibleHeight();
                setDynamicHeight(newVisibleHeight);
            },
            wrapper: "#" + id,
            content: "#" + id + "content"
        });
        
        // No need for manual resize listener - handled by utility
    }, { dependencies: [id, images.length]});

    return (
        <>
            <style jsx>{`
                #${id} {
                    position: relative !important;
                    inset: auto !important;
                    overflow: hidden !important;
                    height: ${dynamicHeight}px !important;
                    min-height: 100vh;
                }  
            `}</style>
            <div className="w-full" id={id}>
                <div className="w-full" id={id + "content"}>
                    <div className="flex flex-col w-full justify-center items-center">
                        {images.map((img, index) => (
                                <Image 
                                    key={index}
                                    src={img.src} 
                                    alt={img.alt}
                                    width={500}
                                    height={500}
                                    className={`w-1/4 aspect-square object-cover object-top ${index % 2 === 1 ? 'mr-[150px]' : '-mr-[150px]'}`}
                                    data-speed={img.speed}
                                />
                        ))}
                    </div>
                </div>
            </div>
        </>
    );
}

export { ScrollingGallery };