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.

gsapsliderparallaxgallerythumbnailsobserverdrag

Setup — External Scripts

CDN — GSAP (add before </body>)
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/gsap.min.js"></script>
CDN — Observer (add after GSAP)
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/Observer.min.js"></script>
CDN — CustomEase (add after Observer)
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/CustomEase.min.js"></script>

Code

HTML
html
<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>
CSS
css
.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;
  }
}
JavaScript
javascript
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

NameTypeDefaultDescription
[data-slideshow="wrap"]attributeRoot 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"]attributeEach 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"]attributeThe 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"]attributeClickable 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.
animationDurationnumber0.9Duration 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.