Parallax Image Gallery with Thumbnails
A full-viewport image slider with a horizontal parallax transition — slides move at full speed while inner images move at 50% in the opposite direction. Navigable by thumbnail click, drag, and horizontal mouse wheel. Powered by GSAP Observer and a custom ease.
Setup — External Scripts
<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/Observer.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/CustomEase.min.js"></script>Code
<div data-slideshow="wrap" class="img-slider">
<div class="img-slider__list">
<div data-slideshow="slide" class="img-slide is--current"><img data-slideshow="parallax" alt="" src="https://cdn.prod.website-files.com/67db521fe639fd09f860b106/67dbf80921b87fe739e3cbd2_osmo-slideshow-img-3.avif" draggable="false" class="img-slide__inner"></div>
<div data-slideshow="slide" class="img-slide"><img data-slideshow="parallax" alt="" src="https://cdn.prod.website-files.com/67db521fe639fd09f860b106/67dbf809f124211fa71791fa_osmo-slideshow-img-1.avif" draggable="false" class="img-slide__inner"></div>
<div data-slideshow="slide" class="img-slide"><img data-slideshow="parallax" alt="" src="https://cdn.prod.website-files.com/67db521fe639fd09f860b106/67dbf809e5434a14205e65b2_osmo-slideshow-img-2.avif" draggable="false" class="img-slide__inner"></div>
<div data-slideshow="slide" class="img-slide"><img data-slideshow="parallax" alt="" src="https://cdn.prod.website-files.com/67db521fe639fd09f860b106/67dbf809fb316a147c9d1dda_osmo-slideshow-img-4.avif" draggable="false" class="img-slide__inner"></div>
</div>
<div class="img-slider__nav">
<div data-slideshow="thumb" class="img-slider__thumb is--current"><img src="https://cdn.prod.website-files.com/67db521fe639fd09f860b106/67dbf80921b87fe739e3cbd2_osmo-slideshow-img-3.avif" class="slider-thumb__img"></div>
<div data-slideshow="thumb" class="img-slider__thumb"><img src="https://cdn.prod.website-files.com/67db521fe639fd09f860b106/67dbf809f124211fa71791fa_osmo-slideshow-img-1.avif" class="slider-thumb__img"></div>
<div data-slideshow="thumb" class="img-slider__thumb"><img src="https://cdn.prod.website-files.com/67db521fe639fd09f860b106/67dbf809e5434a14205e65b2_osmo-slideshow-img-2.avif" class="slider-thumb__img"></div>
<div data-slideshow="thumb" class="img-slider__thumb"><img src="https://cdn.prod.website-files.com/67db521fe639fd09f860b106/67dbf809fb316a147c9d1dda_osmo-slideshow-img-4.avif" class="slider-thumb__img"></div>
</div>
</div>.img-slider {
grid-column-gap: 1rem;
grid-row-gap: 1rem;
border-radius: .5em;
justify-content: center;
align-items: flex-end;
width: 100%;
height: 100vh;
display: flex;
position: relative;
}
.img-slider__list {
grid-template-rows: 100%;
grid-template-columns: 100%;
place-items: center;
width: 100%;
height: 100%;
display: grid;
overflow: hidden;
}
.img-slide {
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
grid-area: 1 / 1 / -1 / -1;
place-items: center;
width: 100%;
height: 100%;
display: grid;
position: relative;
overflow: hidden;
}
.img-slide.is--current {
opacity: 1;
pointer-events: auto;
}
.img-slide__inner {
object-fit: cover;
will-change: transform;
width: 100%;
height: 100%;
position: absolute;
}
.img-slider__nav {
z-index: 2;
grid-column-gap: .5rem;
grid-row-gap: .5rem;
pointer-events: none;
flex-flow: wrap;
justify-content: center;
align-items: center;
max-width: 95vw;
display: flex;
position: absolute;
bottom: 2rem;
}
.img-slider__thumb {
aspect-ratio: 1.5;
pointer-events: auto;
cursor: pointer;
border: 1px solid #fff3;
border-radius: .3125rem;
width: 7rem;
transition: border-color .2s;
position: relative;
overflow: hidden;
}
.img-slider__thumb:hover {
border-color: #fff6;
}
.img-slider__thumb.is--current {
border-color: #fff;
}
.slider-thumb__img {
object-fit: cover;
width: 100%;
height: 100%;
}
@media screen and (max-width: 991px) {
.img-slider__list {
width: 100%;
}
.img-slider__thumb {
flex: none;
}
}
@media screen and (max-width: 767px) {
.img-slider__nav {
flex-flow: wrap;
}
.img-slider__thumb {
border-radius: .25rem;
width: 5rem;
}
}
@media screen and (max-width: 479px) {
.img-slider__thumb {
width: 4.5rem;
}
}gsap.registerPlugin(Observer, CustomEase);
CustomEase.create("slideshow-wipe", "0.6, 0.08, 0.02, 0.99");
function initSlideShow(el) {
// Save all elements in an object for easy reference
const ui = {
el,
slides: Array.from(el.querySelectorAll('[data-slideshow="slide"]')),
inner: Array.from(el.querySelectorAll('[data-slideshow="parallax"]')),
thumbs: Array.from(el.querySelectorAll('[data-slideshow="thumb"]'))
};
let current = 0;
const length = ui.slides.length;
let animating = false;
let observer;
let animationDuration = 0.9; // Define the duration of your 'slide' here
ui.slides.forEach((slide, index) => { slide.setAttribute('data-index', index); });
ui.thumbs.forEach((thumb, index) => { thumb.setAttribute('data-index', index); });
ui.slides[current].classList.add('is--current');
ui.thumbs[current].classList.add('is--current');
function navigate(direction, targetIndex = null) {
if (animating) return;
animating = true;
observer.disable();
const previous = current;
current =
targetIndex !== null && targetIndex !== undefined
? targetIndex
: direction === 1
? current < length - 1 ? current + 1 : 0
: current > 0 ? current - 1 : length - 1;
const currentSlide = ui.slides[previous];
const currentInner = ui.inner[previous];
const upcomingSlide = ui.slides[current];
const upcomingInner = ui.inner[current];
gsap.timeline({
defaults: { duration: animationDuration, ease: 'slideshow-wipe' },
onStart: function() {
upcomingSlide.classList.add('is--current');
ui.thumbs[previous].classList.remove('is--current');
ui.thumbs[current].classList.add('is--current');
},
onComplete: function() {
currentSlide.classList.remove('is--current');
animating = false;
setTimeout(() => observer.enable(), animationDuration);
}
})
.to(currentSlide, { xPercent: -direction * 100 }, 0)
.to(currentInner, { xPercent: direction * 50 }, 0)
.fromTo(upcomingSlide, { xPercent: direction * 100 }, { xPercent: 0 }, 0)
.fromTo(upcomingInner, { xPercent: -direction * 50 }, { xPercent: 0 }, 0);
}
function onClick(event) {
const targetIndex = parseInt(event.currentTarget.getAttribute('data-index'), 10);
if (targetIndex === current || animating) return;
const direction = targetIndex > current ? 1 : -1;
navigate(direction, targetIndex);
}
ui.thumbs.forEach(thumb => { thumb.addEventListener('click', onClick); });
observer = Observer.create({
target: el,
type: 'wheel,touch,pointer',
onLeft: () => { if (!animating) navigate(1); },
onRight: () => { if (!animating) navigate(-1); },
onWheel: (event) => {
if (animating) return;
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
if (event.deltaX > 50) navigate(1);
else if (event.deltaX < -50) navigate(-1);
}
},
wheelSpeed: -1,
tolerance: 10
});
return {
destroy: function() {
if (observer) observer.kill();
ui.thumbs.forEach(thumb => { thumb.removeEventListener('click', onClick); });
}
};
}
function initParallaxImageGalleryThumbnails() {
document.querySelectorAll('[data-slideshow="wrap"]').forEach(wrap => initSlideShow(wrap));
}
// Initialize Parallax Image Gallery with Thumbnails
document.addEventListener('DOMContentLoaded', () => {
initParallaxImageGalleryThumbnails();
});Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| [data-slideshow="wrap"] | attribute | — | Root container for each slider instance. Holds both the slides and thumbnails. GSAP Observer attaches wheel, touch, and drag listeners to this element. |
| [data-slideshow="slide"] | attribute | — | Each individual slide. All slides stack in the same grid cell via CSS grid-area. The active slide has .is--current; the outgoing slide loses it on animation completion. |
| [data-slideshow="parallax"] | attribute | — | The inner image element inside each slide. Moves at 50% speed in the opposite direction to the slide during transitions, creating the parallax depth effect. |
| [data-slideshow="thumb"] | attribute | — | Clickable thumbnail that directly navigates to its associated slide. Receives .is--current when its slide is active. Direction of animation is determined by comparing the thumbnail's data-index to the current slide index. |
| animationDuration | number | 0.9 | Duration in seconds for all slide transitions. Change in the script to adjust overall speed. |
Notes
- •Requires GSAP, Observer, and CustomEase loaded via CDN before the script runs.
- •All slides use CSS grid-area to stack in the same cell — only the .is--current slide is visible (opacity: 1) and interactive (pointer-events: auto).
- •draggable="false" on images prevents the browser's native image drag from interfering with the Observer pointer events.
- •Observer is disabled during animation and re-enabled via setTimeout(animationDuration) to prevent rapid navigation from stacking transitions.
- •The destroy() method kills the Observer and removes all thumbnail click listeners — use it for SPA / page transition cleanup.
- •The is--current class is applied to both slides and thumbnails simultaneously at onStart, so thumbnail state updates are instant and never lag behind the animation.
Guide
Container
Use [data-slideshow="wrap"] as the root container for each parallax slider instance. This element holds both the slides and the thumbnails, and is the target for GSAP's Observer plugin to capture wheel, touch, and drag navigation.
Slide
Use [data-slideshow="slide"] for each individual slide. The active slide automatically receives the is--current class, while the outgoing slide has it removed at animation completion.
Parallax
Use [data-slideshow="parallax"] inside each slide to create the inner layer that moves at half the speed of the main slide during transitions, producing the parallax effect.
Thumbnail
Use [data-slideshow="thumb"] for clickable thumbnails that directly navigate to their associated slide. The active thumbnail also gets the .is--current class. The script auto-assigns data-index to both slides and thumbs to link them and determine animation direction.
Animation
Transitions are powered by GSAP timelines using the custom ease "slideshow-wipe". Slides animate horizontally (xPercent), while their parallax elements move in the opposite direction at 50% speed. Change animationDuration (default 0.9s) or redefine the CustomEase curve to customize the feel.
// Change transition speed
let animationDuration = 0.9;
// Change easing curve
CustomEase.create("slideshow-wipe", "0.6, 0.08, 0.02, 0.99");Navigation
Navigate by clicking a [data-slideshow="thumb"], dragging left/right, or using horizontal mouse wheel input (only triggers when |deltaX| > |deltaY|). Observer disables itself while an animation runs and re-enables after animationDuration to prevent navigation spam.
Multiple Instances
Place multiple [data-slideshow="wrap"] containers on the page. initParallaxImageGalleryThumbnails() automatically initializes each one. Each instance is fully independent with its own state and Observer.
Webflow CMS Integration
For CMS use in Webflow, create two Collection Lists: one for [data-slideshow="slide"] and one for [data-slideshow="thumb"]. Apply the attributes directly to CMS list items. Ensure both lists have the same item count and identical ordering to keep them in sync.