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>
[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;
}
<!-- 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>
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)
}
| 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 |
| 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 |
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.
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"