Crafting a Scrollable and Draggable Parallax Slider

A tutorial on how to build a slider with an interesting parallax effect that you can either scroll or drag through.

In this article, I’ll show you how to build a parallax slider with a fun reveal animation. I’ll be using GSAP, CSS Grid and Flexbox and I’ll assume that you have some basic knowledge on how to use these techniques. Besides that, I’ll be using a custom smooth scroll based on Virtual Scroll for a better experience.

The article is split up in 3 steps:

  1. The hover animation
  2. The open/expand animation
  3. The slider scroll/drag parallax effect

 For a better understanding I added motion videos for each step.

1. Hover animation

When we hover the button on the top right, 3 placeholder images will animate in the viewport from the right side.

Markup

<button class="button-slider-open js-slider-open" type="button">
  <svg>...</svg>
</button>

<div class="placeholders js-placeholders">
  <div class="placeholders__img-wrap js-img-wrap" style="--aspect-ratio: 0.8;">
    <img
      src="https://images.unsplash.com/photo-1479839672679-a46483c0e7c8?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=80&ar=0.8"
      class="placeholders__img"
    >
  </div>

  ...
</div>

Animation

We have a this.dom object where we store all the DOM elements such as the images. When initializing the app we call the setHoverAnimation which creates a GSAP timeline that is set default to paused. In the timeline we will animate the 3 images into the viewport so they’re partly visible. The user will then have the impression that they can be expanded on click.

this.dom = {};
this.dom.el = document.querySelector('.js-placeholders');
this.dom.images = this.dom.el.querySelectorAll('.js-img-wrap');
this.dom.buttonOpen = document.querySelector('.js-slider-open');
setHoverAnimation() {
  this.tl = gsap.timeline({ paused: true });

  this.tl
    .addLabel('start')

    .set(this.dom.el, { autoAlpha: 1 })
    .set(this.dom.images, { scale: 0.5, x: (window.innerWidth / 12) * 1.2, rotation: 0 })

    .to(this.dom.images, { duration: 1, stagger: 0.07, ease: 'power3.inOut', x: 0, y: 0 })
    .to(this.dom.images[0], { duration: 1, ease: 'power3.inOut', rotation: -4 }, 'start')
    .to(this.dom.images[1], { duration: 1, ease: 'power3.inOut', rotation: -2 }, 'start');
}

To trigger the animation when hovering the button, we created 2 events (handleMouseenter and handleMouseleave) which will play the GSAP timeline or simply reverse the animation.

this.dom.buttonOpen.addEventListener('mouseenter', this.handleMouseenter);
this.dom.buttonOpen.addEventListener('mouseleave', this.handleMouseleave);

handleMouseenter() {
  this.tl.play();
}

handleMouseleave() {
  this.tl.reverse();
}

2. Expand placeholder items

When we click the button on the top right, after hovering, the 3 placeholder images will animate to a 3 column grid. There will be more than 3 items in the slider, but only the first 3 will be visible in the viewport so it’s not necessary to animate the other items. Once the 3 placeholder items are in the correct position we can display the actual slider items underneath and remove the placeholder items.

Animation

First we have to calculate the position that the placeholder items will have to animate to. Subtracting the left position of the placeholder items with the left position of the slider items will get you the correct x position. To get the y position I’m doing the exact same thing, but use the top bounds instead.

const x1 = this.bounds.left - slider.items[0].bounds.left - 20;
const x2 = this.bounds.left - slider.items[1].bounds.left + 10;
const x3 = this.bounds.left - slider.items[2].bounds.left;

const y1 = this.bounds.top - slider.items[0].bounds.top + 10;
const y2 = this.bounds.top - slider.items[1].bounds.top - 30;
const y3 = this.bounds.top - slider.items[2].bounds.top + 30;

The placeholder items are smaller than the slider items so we have to scale them up. To calculate the scale value we will just divide the width of the placeholder items by the width of one of the slider items (they are all the same size).

const scale = slider.items[0].bounds.width / this.bounds.width;

The intersectX1 X2 X3 values are used to set the initial x position of the images inside its container. Each image in the slider will have its own x position stored. The x position will be used to animate the slider images inside its container, this will create the parallax effect.

const intersectX1 = slider.items[0].x;
const intersectX2 = slider.items[1].x;
const intersectX3 = slider.items[2].x;

A new GSAP timeline will be created for the expand animation. Once the timeline is completed setHoverAnimation will be fired so the placeholder items are reset and ready to animate again. Also we will start the reveal animation of the slider elements such as the text on the items and the close button, but we will not go into the text animations. You can find that in the final code.

this.tl = gsap.timeline({
  onComplete: () => {
    this.setHoverAnimation();
    slider.open();
  }
});

The 3 placeholder images will animate to the x and y position that we defined earlier. Let’s have a look at the GSAP timeline.

this.tl
  .addLabel('start')
  .to(this.dom.images[0], { duration: 1.67, ease: 'power3.inOut', x: -x1, y: -y1, scale, rotation: 0 }, 'start')
  .to(this.dom.images[1], { duration: 1.67, ease: 'power3.inOut', x: -x2, y: -y2, scale, rotation: 0 }, 'start')
  .to(this.dom.images[2], { duration: 1.67, ease: 'power3.inOut', x: -x3, y: -y3, scale, rotation: 0 }, 'start')
  .to(this.dom.images[0].querySelector('img'), { duration: 1.67, ease: 'power3.inOut', x: intersectX1 }, 'start')
  .to(this.dom.images[1].querySelector('img'), { duration: 1.67, ease: 'power3.inOut', x: intersectX2 }, 'start')
  .to(this.dom.images[2].querySelector('img'), { duration: 1.67, ease: 'power3.inOut', x: intersectX3 }, 'start',)
  .set(this.dom.el, { autoAlpha: 0 }, 'start+=1.67')

3. Parallax effect

The images are scaled up in its container that will have `overflow: hidden`. Once you move the slider, the images that are in the viewport will move with a slightly different speed.

Markup

<div class="slider js-slider">
  <div class="slider__container js-container" data-scroll>
    <div class="slider__item js-item" style="--aspect-ratio: 0.8;">
      <div class="slider__item-img-wrap js-img-wrap js-img" style="--aspect-ratio: 0.8;">
        <img
          src="https://images.unsplash.com/photo-1472835560847-37d024ebacdc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=80&ar=0.8"
          class="slider__item-img"
        >
      </div>

      <div class="slider__item-content">
        <div class="slider__item-heading-wrap">
          <h3 class="slider__item-heading">
            Indigo
          </h3>
        </div>

        <div class="slider__item-button-wrap">
          <button class="button slider__item-button" type="button">
            Read more
          </button>
        </div>
      </div>
    </div>

    ...
  </div>
</div>
<div class="slider__progress-wrap js-progress-wrap">
  <div class="slider__progress js-progress"></div>
</div>

Animation

We will be using a smooth scroll on top of Virtual Scroll, but we will not go into how to set this up (please check the documentation). With interpolation, we can achieve this smooth parallax scrolling effect.

Once again we have a this.dom object where we will store all the DOM elements that we need.

this.dom = {};
this.dom.el = document.querySelector('.js-slider');
this.dom.container = this.dom.el.querySelector('.js-container');
this.dom.items = this.dom.el.querySelectorAll('.js-item');
this.dom.images = this.dom.el.querySelectorAll('.js-img-wrap');
this.dom.progress = this.dom.el.querySelector('.js-progress');

To keep track of the scroll progress we created a simple progress bar which defines how much we moved the slider. We simply calculate a value between 0 and 1 that represents the x position of the slider container. This value will be updated in a requestAnimationFrame and used to animate the scaleX value of the progress bar.

const max = -this.dom.container.offsetWidth + window.innerWidth;
const progress = ((scroll.state.last - 0) * 100) / (max - 0) / 100;

this.dom.progress.style.transform = `scaleX(${progress})`;

Before we start the animation of the parallax effect on the images we first store some data of each slider item in the array this.items. This array wil contain the image element, the bounds and the x position of the image.

setCache() {
  this.items = [];
  [...this.dom.items].forEach((el) => {
    const bounds = el.getBoundingClientRect();

    this.items.push({
      img: el.querySelector('img'),
      bounds,
      x: 0,
    });
  });
}

Now we’re finally ready to create the parallax effect on the images. The code below will be executed in a render function in a requestAnimationFrame. In this function we will be using the interpolated value of our scroll (scroll.state.last).

For a better performance we will only animate the images that are in the viewport. To do so we will detect which items are visible in the viewport.

const { bounds } = item;
const inView = scrollLast + window.innerWidth >= bounds.left && scrollLast < bounds.right;

If the item is visible in the viewport, we will calculate a value between 0 and 100 (percentage) that indicates how much of the target element is actually visible within the viewport.

const min = bounds.left - window.innerWidth;
const max = bounds.right;
const percentage = ((scrollLast - min) * 100) / (max - min);

Once we have that value stored, we can calculate a new value based on percentage that we will then transform into a pixel value like this.

const newMin = -(window.innerWidth / 12) * 3;
const newMax = 0;
item.x = ((percentage - 0) / (100 - 0)) * (newMax - newMin) + newMin;

After calculating that final x value we can now simply animate the image inside its container like this.

item.img.style.transform = `translate3d(${item.x}px, 0, 0)`;

This is what the render function looks like.

render() {
  const scrollLast = scroll.state.last;

  this.items.forEach((item) => {
    const { bounds } = item;
    const inView = scrollLast + window.innerWidth >= bounds.left && scrollLast < bounds.right;

    if (inView) {
      const min = bounds.left - window.innerWidth;
      const max = bounds.right;
      const percentage = ((scrollLast - min) * 100) / (max - min);
      const newMin = -(window.innerWidth / 12) * 3;
      const newMax = 0;
      item.x = ((percentage - 0) / (100 - 0)) * (newMax - newMin) + newMin;

      item.img.style.transform = `translate3d(${item.x}px, 0, 0) scale(1.75)`;
    }
  });
}

I hope this has been not too difficult to follow and that you have gained some insight into creating this parallax slider.

If you would like to see this slider live in action, let’s have a look at the architectural website for Nieuw Bergen by Gewest13. It’s used to showcase their seven buildings.

Please let me know if you have any questions @rluijtenant.

Ruud Luijten

Freelance creative developer.

Stay in the loop: Get your dose of frontend twice a week

👾 Hey! Looking for the latest in frontend? Twice a week, we'll deliver the freshest frontend news, website inspo, cool code demos, videos and UI animations right to your inbox.

Zero fluff, all quality, to make your Mondays and Thursdays more creative!