1. CDN Scripts

Add these to your <head> or before closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/SplitText.min.js"></script>

2. Required CSS

[data-infinite-gallery] {
  position: relative;
  width: 100%;
  height: 100vh;
  overflow: hidden;
  cursor: default;
}

[data-gallery-canvas] {
  position: absolute;
  will-change: transform;
}

.gallery-item {
  position: absolute;
  overflow: hidden;
  background-color: #000;
  cursor: pointer;
}

.gallery-item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  pointer-events: none;
}

.gallery-expanded-item {
  position: fixed;
  z-index: 100;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background-color: #e3e3db;
  overflow: hidden;
  cursor: pointer;
}

.gallery-expanded-item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  pointer-events: none;
}

[data-gallery-overlay] {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: #e3e3db;
  pointer-events: none;
  transition: opacity 0.3s ease;
  opacity: 0;
  z-index: 2;
}

[data-gallery-overlay].active {
  pointer-events: auto;
  opacity: 1;
}

[data-gallery-title] {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 100%;
  text-align: center;
  pointer-events: none;
  z-index: 10000;
}

[data-gallery-title] p {
  position: relative;
  height: 42px;
  color: #fff;
  clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);
}

[data-gallery-title] p .word {
  position: relative;
  display: inline-block;
  font-family: "Inter";
  font-size: 36px;
  letter-spacing: -0.02rem;
  margin-right: 0.1em;
  transform: translateY(0%);
  will-change: transform;
}

3. HTML Structure

<!-- Gallery Container -->
<div
  data-infinite-gallery
  data-item-width="120"
  data-item-height="160"
  data-item-gap="150"
  data-columns="4"
  data-scroll-speed="3"
  data-image-count="20"
  data-image-pattern="/img{n}.jpg"
  data-titles="Title One|Title Two|Title Three"
>
  <div data-gallery-canvas></div>
  <div data-gallery-overlay></div>
</div>

<!-- Title Display (place anywhere on page) -->
<div data-gallery-title>
  <p></p>
</div>

4. JavaScript

gsap.registerPlugin(CustomEase, SplitText);

document.addEventListener("DOMContentLoaded", () => {
  CustomEase.create("hop", "0.9, 0, 0.1, 1");
  document.querySelectorAll("[data-infinite-gallery]").forEach(initGallery);
});

function initGallery(container) {
  const itemWidth = parseInt(container.dataset.itemWidth) || 120;
  const itemHeight = parseInt(container.dataset.itemHeight) || 160;
  const itemGap = parseInt(container.dataset.itemGap) || 150;
  const columns = parseInt(container.dataset.columns) || 4;
  const scrollSpeed = parseFloat(container.dataset.scrollSpeed) || 3;
  const easeFactor = parseFloat(container.dataset.easeFactor) || 0.075;
  const imageCount = parseInt(container.dataset.imageCount) || 20;
  const imagePattern = container.dataset.imagePattern || "/img{n}.jpg";

  const defaultTitles = [
    "Chromatic Loopscape","Solar Bloom","Neon Handscape","Echo Discs",
    "Void Gaze","Gravity Sync","Heat Core","Fractal Mirage",
    "Nova Pulse","Sonic Horizon","Dream Circuit","Lunar Mesh",
    "Radiant Dusk","Pixel Drift","Vortex Bloom","Shadow Static",
    "Crimson Phase","Retro Cascade","Photon Fold","Zenith Flow",
  ];
  const titles = container.dataset.titles
    ? container.dataset.titles.split("|").map((t) => t.trim())
    : defaultTitles;

  const canvas = container.querySelector("[data-gallery-canvas]");
  const overlay = container.querySelector("[data-gallery-overlay]");
  const titleEl = document.querySelector("[data-gallery-title] p");

  const state = {
    targetX: 0, targetY: 0,
    currentX: 0, currentY: 0,
    visibleItems: new Set(),
    lastUpdateTime: 0, lastX: 0, lastY: 0,
    isExpanded: false, activeItem: null,
    canMove: true, originalPosition: null,
    expandedItem: null, activeItemId: null,
    titleSplit: null, animationFrameId: null,
    touchStartX: 0, touchStartY: 0,
  };

  // ... (full source in the live script below)
}

5. Data Attributes Reference

Attribute Default Description
data-infinite-gallery required Enables the gallery on a container
data-item-width 120 Width of each grid item in px
data-item-height 160 Height of each grid item in px
data-item-gap 150 Gap between items in px
data-columns 4 Number of columns for image distribution
data-scroll-speed 3 Scroll/wheel speed multiplier
data-ease-factor 0.075 Smoothing factor (lower = smoother)
data-image-count 20 Number of unique images to cycle
data-image-pattern /img{n}.jpg Image URL pattern ({n} replaced with 1-based index)
data-titles built-in list Pipe-separated list of titles for expanded view

6. Child Elements

Attribute Element Description
data-gallery-canvas <div> The panning surface (items are appended here)
data-gallery-overlay <div> Background overlay for expanded view
data-gallery-title <div> Contains a <p> for the animated title text

7. Webstudio Usage

In Webstudio, add the three CDN scripts (gsap, CustomEase, SplitText) to your project's custom code (head). Paste the Required CSS into your project's global styles. Create a Box element and add the data-infinite-gallery attribute along with the configuration attributes. Inside it, add two child Box elements with data-gallery-canvas and data-gallery-overlay attributes. Place a separate Box with data-gallery-title containing a Paragraph element anywhere on the page. Finally, add the JavaScript into a custom code embed or the project's body custom code. Update data-image-pattern with your own image URLs and adjust data-titles to match.

8. Using Local Images

Replace the data-image-pattern attribute with your own image path pattern. Use {n} as a placeholder for the image number (1-based). For example:

<!-- Local images -->
data-image-pattern="/images/photo{n}.jpg"

<!-- External CDN -->
data-image-pattern="https://picsum.photos/seed/gallery{n}/240/320"

<!-- Custom path -->
data-image-pattern="/uploads/gallery/img-{n}.webp"