Time loosens its grip and the stack begins to shift

Eventually, the stack settles and the scroll continues

1. CDN Scripts

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>

2. Required CSS

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

3. JavaScript

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

4. HTML Usage

<!-- 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>

5. Data Attributes Reference

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

6. Image Folder Structure

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.

7. CSS Custom Properties (Optional)

/* 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 */
}