Add these to your <head> or before closing </body> tag:
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/ScrollTrigger.min.js"></script>
:root {
--scatter-bg: #141414;
--scatter-card-border: #4a4a4a;
--scatter-text: #fff;
}
[data-photo-scatter] {
position: relative;
width: 100%;
height: 100svh;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
background-color: var(--scatter-bg);
}
[data-photo-scatter] h1 {
width: 45%;
text-align: center;
will-change: opacity;
z-index: 2;
color: var(--scatter-text);
font-size: clamp(3rem, 5vw, 7vw);
font-weight: 500;
line-height: 0.9;
letter-spacing: -0.025rem;
}
.scatter-card {
position: absolute;
border-radius: 1rem;
border: 0.5rem solid var(--scatter-card-border);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.25);
will-change: transform;
overflow: hidden;
}
.scatter-card img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.5rem;
}
@media (max-width: 1000px) {
[data-photo-scatter] h1 {
width: 100%;
padding: 2rem;
}
}
gsap.registerPlugin(ScrollTrigger);
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("[data-photo-scatter]").forEach((gallery) => {
const CONFIG = {
cardCount: parseInt(gallery.dataset.cardCount) || 15,
cardWidth: parseInt(gallery.dataset.cardWidth) || 250,
cardHeight: parseInt(gallery.dataset.cardHeight) || 300,
duration: parseFloat(gallery.dataset.duration) || 0.75,
overlap: parseFloat(gallery.dataset.overlap) || 0.5,
headingFade: 0.5,
imagePath: gallery.dataset.imagePath || "https://picsum.photos/seed/scatter",
sets: parseInt(gallery.dataset.sets) || 4,
headings: gallery.dataset.headings
? gallery.dataset.headings.split("|")
: ["Set 1", "Set 2", "Set 3", "Set 4"],
};
const galleryHeading = gallery.querySelector("h1");
let viewport = { centerX: 0, centerY: 0, rangeMin: 0, rangeMax: 0 };
let state = { activeCards: [], currentSection: 0, isAnimating: false };
function updateViewport() {
viewport.centerX = window.innerWidth / 2;
viewport.centerY = window.innerHeight / 2;
viewport.rangeMin = Math.min(window.innerWidth, window.innerHeight) * 0.35;
viewport.rangeMax = Math.min(window.innerWidth, window.innerHeight) * 0.7;
}
function getEdgePosition(cx, cy) {
const d = {
left: cx,
right: window.innerWidth - cx,
top: cy,
bottom: window.innerHeight - cy,
};
const min = Math.min(...Object.values(d));
const offX = CONFIG.cardWidth / 2;
const offY = CONFIG.cardHeight / 2;
const vary = () => (Math.random() - 0.5) * 400;
if (min === d.left)
return { x: -300 - Math.random() * 200, y: cy - offY + vary() };
if (min === d.right)
return { x: window.innerWidth + 50 + Math.random() * 200, y: cy - offY + vary() };
if (min === d.top)
return { x: cx - offX + vary(), y: -400 - Math.random() * 200 };
return { x: cx - offX + vary(), y: window.innerHeight + 50 + Math.random() * 200 };
}
function createCards(setNumber) {
const cards = [];
for (let i = 0; i < CONFIG.cardCount; i++) {
const card = document.createElement("div");
card.classList.add("scatter-card");
card.style.width = CONFIG.cardWidth + "px";
card.style.height = CONFIG.cardHeight + "px";
const img = document.createElement("img");
img.src = CONFIG.imagePath + setNumber + "-" + (i + 1) + "/" + CONFIG.cardWidth + "/" + CONFIG.cardHeight;
card.appendChild(img);
const angle = Math.random() * Math.PI * 2;
const radius = viewport.rangeMin +
Math.random() * (viewport.rangeMax - viewport.rangeMin);
const cx = viewport.centerX + Math.cos(angle) * radius;
const cy = viewport.centerY + Math.sin(angle) * radius;
gsap.set(card, {
left: cx - CONFIG.cardWidth / 2,
top: cy - CONFIG.cardHeight / 2,
rotation: Math.random() * 50 - 25,
});
gallery.appendChild(card);
cards.push({ element: card, centerX: cx, centerY: cy });
}
return cards;
}
function animateHeading(newText) {
return gsap.timeline()
.to(galleryHeading, {
opacity: 0, duration: CONFIG.headingFade, ease: "power2.inOut",
})
.call(() => { galleryHeading.textContent = newText; })
.to(galleryHeading, {
opacity: 1, duration: CONFIG.headingFade, ease: "power2.inOut",
});
}
function animateCards(exitingCards, enteringCards) {
const tl = gsap.timeline();
exitingCards.forEach(({ element, centerX, centerY }) => {
const edge = getEdgePosition(centerX, centerY);
tl.to(element, {
left: edge.x, top: edge.y,
rotation: Math.random() * 180 - 90,
duration: CONFIG.duration, ease: "power2.in",
onComplete: () => element.remove(),
}, 0);
});
enteringCards.forEach(({ element, centerX, centerY }) => {
const edge = getEdgePosition(centerX, centerY);
gsap.set(element, {
left: edge.x, top: edge.y,
rotation: Math.random() * 180 - 90,
});
tl.to(element, {
left: centerX - CONFIG.cardWidth / 2,
top: centerY - CONFIG.cardHeight / 2,
rotation: Math.random() * 50 - 25,
duration: CONFIG.duration, ease: "power2.out",
}, CONFIG.overlap);
});
return tl;
}
function getSectionIndex(progress) {
const step = 1 / CONFIG.sets;
return Math.min(Math.floor(progress / step), CONFIG.sets - 1);
}
function reinitialize() {
state.activeCards.forEach(({ element }) => element.remove());
updateViewport();
state.activeCards = createCards(state.currentSection + 1);
}
// Initialize
updateViewport();
state.activeCards = createCards(1);
galleryHeading.textContent = CONFIG.headings[0];
gsap.set(galleryHeading, { opacity: 1 });
ScrollTrigger.create({
trigger: gallery,
start: "top top",
end: () => "+=" + (window.innerHeight * 6),
pin: true,
pinSpacing: true,
onUpdate: ({ progress }) => {
if (state.isAnimating) return;
const target = getSectionIndex(progress);
if (target === state.currentSection) return;
state.isAnimating = true;
const newCards = createCards(target + 1);
Promise.all([
animateCards(state.activeCards, newCards).then(),
animateHeading(CONFIG.headings[target]).then(),
]).then(() => {
state.activeCards = newCards;
state.currentSection = target;
state.isAnimating = false;
});
},
});
window.addEventListener("resize", () => {
reinitialize();
ScrollTrigger.refresh();
});
});
});
<!-- Basic usage (all defaults) -->
<section data-photo-scatter>
<h1></h1>
</section>
<!-- Full configuration -->
<section
data-photo-scatter
data-card-count="15"
data-card-width="250"
data-card-height="300"
data-duration="0.75"
data-overlap="0.5"
data-image-path="public/set"
data-sets="4"
data-headings="First heading|Second heading|Third heading|Fourth heading"
>
<h1></h1>
</section>
<!-- Smaller cards, faster animation -->
<section
data-photo-scatter
data-card-count="10"
data-card-width="180"
data-card-height="220"
data-duration="0.5"
data-image-path="images/gallery"
data-sets="3"
data-headings="One|Two|Three"
>
<h1></h1>
</section>
| Attribute | Default | Description |
|---|---|---|
data-photo-scatter |
required | Enables the scatter animation on a section |
data-card-count |
15 |
Number of photo cards per set |
data-card-width |
250 |
Card width in pixels |
data-card-height |
300 |
Card height in pixels |
data-duration |
0.75 |
Animation speed in seconds |
data-overlap |
0.5 |
Overlap between exit and enter animations |
data-image-path |
public/set |
Base path for image folders (appends set number) |
data-sets |
4 |
Number of image sets to cycle through |
data-headings |
Set 1|Set 2|... |
Pipe-separated heading text for each set |
public/
├── set1/
│ ├── img1.jpg
│ ├── img2.jpg
│ └── ... img15.jpg
├── set2/
│ ├── img1.jpg
│ └── ...
├── set3/
│ └── ...
└── set4/
└── ...
Images are loaded as
{data-image-path}{setNumber}/img{index}.jpg.
Make sure your folder structure matches. Each set should contain at
least as many images as data-card-count.
/* Override these variables to change the look */
:root {
--scatter-bg: #141414; /* gallery background */
--scatter-card-border: #4a4a4a; /* card border color */
--scatter-text: #fff; /* heading text color */
}