Getting Started
IntroductionLayout Components
Expandable PanelScrolling 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













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 };