Loading component…
Components /Image Scatter
Smooth scroll-triggered scattered photo gallery
"use client";
import React, { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { cn } from "@/lib/utils";
// Register ScrollTrigger
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger);
}
export interface ScatterSet {
heading: string;
images: string[];
}
export interface ImageScatterProps extends React.HTMLAttributes<HTMLDivElement> {
data: ScatterSet[];
cardWidth?: number;
cardHeight?: number;
animationDuration?: number;
animationOverlap?: number;
headingFadeDuration?: number;
scroller?: string | Element | null;
}
export function ImageScatter({
data,
cardWidth = 250,
cardHeight = 300,
animationDuration = 0.75,
animationOverlap = 0.5,
headingFadeDuration = 0.5,
scroller,
className,
...props
}: ImageScatterProps) {
const containerRef = useRef<HTMLDivElement>(null);
const galleryRef = useRef<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (!containerRef.current || !galleryRef.current || !headingRef.current || data.length === 0) return;
const gallery = galleryRef.current;
const galleryHeading = headingRef.current;
let viewport = {
centerX: containerRef.current.clientWidth / 2,
centerY: containerRef.current.clientHeight / 2,
rangeMin: Math.min(containerRef.current.clientWidth, containerRef.current.clientHeight) * 0.35,
rangeMax: Math.min(containerRef.current.clientWidth, containerRef.current.clientHeight) * 0.7,
};
let state = {
activeCards: [] as { element: HTMLDivElement; centerX: number; centerY: number }[],
currentSection: 0,
isAnimating: false,
};
function updateViewport() {
if (!containerRef.current) return;
viewport.centerX = containerRef.current.clientWidth / 2;
viewport.centerY = containerRef.current.clientHeight / 2;
viewport.rangeMin = Math.min(containerRef.current.clientWidth, containerRef.current.clientHeight) * 0.35;
viewport.rangeMax = Math.min(containerRef.current.clientWidth, containerRef.current.clientHeight) * 0.7;
}
function getEdgePosition(centerX: number, centerY: number) {
const containerWidth = containerRef.current?.clientWidth || window.innerWidth;
const containerHeight = containerRef.current?.clientHeight || window.innerHeight;
const distances = {
left: centerX,
right: containerWidth - centerX,
top: centerY,
bottom: containerHeight - centerY,
};
const minDistance = Math.min(...Object.values(distances));
const cardCenterOffsetX = cardWidth / 2;
const cardCenterOffsetY = cardHeight / 2;
const offsetVariation = () => (Math.random() - 0.5) * 400;
if (minDistance === distances.left) {
return {
x: -cardWidth - 100 - Math.random() * 200,
y: centerY - cardCenterOffsetY + offsetVariation(),
};
}
if (minDistance === distances.right) {
return {
x: containerWidth + 50 + Math.random() * 200,
y: centerY - cardCenterOffsetY + offsetVariation(),
};
}
if (minDistance === distances.top) {
return {
x: centerX - cardCenterOffsetX + offsetVariation(),
y: -cardHeight - 100 - Math.random() * 200,
};
}
return {
x: centerX - cardCenterOffsetX + offsetVariation(),
y: containerHeight + 50 + Math.random() * 200,
};
}
function createCards(sectionIndex: number) {
const cards: { element: HTMLDivElement; centerX: number; centerY: number }[] = [];
const sectionData = data[sectionIndex];
if (!sectionData || !sectionData.images.length) return cards;
sectionData.images.forEach((src) => {
const card = document.createElement("div");
card.className = "absolute rounded-2xl border-8 border-white dark:border-neutral-800 shadow-xl overflow-hidden will-change-transform";
card.style.width = `${cardWidth}px`;
card.style.height = `${cardHeight}px`;
const img = document.createElement("img");
img.src = src;
img.className = "w-full h-full object-cover rounded-lg pointer-events-none";
card.appendChild(img);
const angle = Math.random() * Math.PI * 2;
const radius = viewport.rangeMin + Math.random() * (viewport.rangeMax - viewport.rangeMin);
const centerX = viewport.centerX + Math.cos(angle) * radius;
const centerY = viewport.centerY + Math.sin(angle) * radius;
gsap.set(card, {
left: centerX - cardWidth / 2,
top: centerY - cardHeight / 2,
rotation: Math.random() * 50 - 25,
});
gallery.appendChild(card);
cards.push({ element: card, centerX, centerY });
});
return cards;
}
function animateHeading(newText: string) {
return gsap
.timeline()
.to(galleryHeading, {
opacity: 0,
duration: headingFadeDuration,
ease: "power2.inOut",
})
.call(() => {
galleryHeading.textContent = newText;
})
.to(galleryHeading, {
opacity: 1,
duration: headingFadeDuration,
ease: "power2.inOut",
});
}
function animateCards(
exitingCards: { element: HTMLDivElement; centerX: number; centerY: number }[],
enteringCards: { element: HTMLDivElement; centerX: number; centerY: number }[]
) {
const tl = gsap.timeline();
exitingCards.forEach(({ element, centerX, centerY }) => {
const targetEdge = getEdgePosition(centerX, centerY);
tl.to(
element,
{
left: targetEdge.x,
top: targetEdge.y,
rotation: Math.random() * 180 - 90,
duration: animationDuration,
ease: "power2.in",
onComplete: () => element.remove(),
},
0
);
});
enteringCards.forEach(({ element, centerX, centerY }) => {
const targetEdge = getEdgePosition(centerX, centerY);
gsap.set(element, {
left: targetEdge.x,
top: targetEdge.y,
rotation: Math.random() * 180 - 90,
});
tl.to(
element,
{
left: centerX - cardWidth / 2,
top: centerY - cardHeight / 2,
rotation: Math.random() * 50 - 25,
duration: animationDuration,
ease: "power2.out",
},
animationOverlap
);
});
return tl;
}
function getSectionIndex(progress: number) {
const totalSections = data.length;
const sectionProgress = Math.min(progress * totalSections, totalSections - 0.01);
return Math.floor(sectionProgress);
}
function reinitialize() {
state.activeCards.forEach(({ element }) => element.remove());
updateViewport();
state.activeCards = createCards(state.currentSection);
}
// Initialize first section
state.activeCards = createCards(0);
galleryHeading.textContent = data[0]?.heading || "";
gsap.set(galleryHeading, { opacity: 1 });
let intervalId: NodeJS.Timeout;
function nextSection() {
if (state.isAnimating) return;
const targetSection = (state.currentSection + 1) % data.length;
state.isAnimating = true;
const newCards = createCards(targetSection);
Promise.all([
animateCards(state.activeCards, newCards).then(),
animateHeading(data[targetSection]?.heading || "").then(),
]).then(() => {
state.activeCards = newCards;
state.currentSection = targetSection;
state.isAnimating = false;
});
}
intervalId = setInterval(nextSection, 3000); // Auto-play every 3s
const handleResize = () => {
reinitialize();
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
clearInterval(intervalId);
state.activeCards.forEach(({ element }) => element.remove());
};
}, [data, cardWidth, cardHeight, animationDuration, animationOverlap, headingFadeDuration]);
return (
<section
ref={containerRef}
className={cn("relative w-full h-full flex justify-center items-center overflow-hidden bg-transparent", className)}
{...props}
>
<div ref={galleryRef} className="absolute inset-0 pointer-events-none" />
<h1
ref={headingRef}
className="w-[90%] md:w-[45%] text-center text-4xl md:text-5xl lg:text-7xl font-serif font-medium leading-tight tracking-tight z-10 will-change-[opacity] text-neutral-900 dark:text-white"
/>
</section>
);
}
Run the following command
npx shadcn@latest add https://vengeance-ui-v2.vercel.app/r/image-scatter.json1import { ImageScatter } from "@/components/ui/image-scatter"23export function ImageScatterDemo() {4 const data = [5 { heading: "Section 1", images: ["/1.jpg", "/2.jpg"] },6 { heading: "Section 2", images: ["/3.jpg", "/4.jpg"] }7 ];8 return <ImageScatter data={data} />9}
| Prop Name | Type | Default | Description |
|---|---|---|---|
| data | ScatterSet[] | - | Array of sections containing a heading and an array of image URLs. |
| cardWidth | number | 250 | Width of each scattered image card in pixels. |
| cardHeight | number | 300 | Height of each scattered image card in pixels. |
| animationDuration | number | 0.75 | Duration of the scattering animation. |
| animationOverlap | number | 0.5 | Overlap timing for entering animations. |
| headingFadeDuration | number | 0.5 | Duration for the heading crossfade. |