簡単3ステップで視差効果スライダーを作ってみよう!【GSAP/CSS Grid/Flexbox】

Scrollable-and Draggable Parallax Slider

コンテンツに奥行きを与える視差効果は、サイトのデザインを洗練させ、上質なサイトづくりに役立ちます。その一方、実装にはハードルが高いと思われることも。

結論から言えば、視差効果を実装するのはそれほど難しくありません。これを活かした「視差効果スライダー」も、以下の3ステップで簡単に作れます。

  1. ホバーアニメーションを実装する
  2. 展開・拡大アニメーションを実装する
  3. 視差効果を実装する

今回はGSAP、CSS Grid、Flexboxを使ったスクロール/ドラッグ可能な視差効果スライダーの作り方を、動画を使いながら紹介します。

ステップ1. ホバーアニメーションを実装する

以下の動画を見てみましょう。右上のボタンにカーソルをあわせると、ビューポートの右から画像が出てきます。

マークアップ

<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>

アニメーション

画像など、すべてのDOM要素はthis.domに格納します。

アプリを初期化する際にsetHoverAnimationを呼び出し、デフォルトをpausedに設定したGSAPタイムラインを作成。タイムラインでは、3つの画像にビューポート内でアニメーションをつけ、画像が一部だけ見えるよう設定します。

こう設定すれば、ユーザーからは「クリックすると展開できる」ように見えますよね。

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');
}

ホバーをアニメーションのトリガーとするために、2つのイベントを作成しました。

handleMouseenterhandleMouseleaveです。これらはそれぞれ、GSAPのタイムラインを再生、アニメーションを逆再生します。

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

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

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

ステップ2. 展開・拡大アニメーションを実装する

ここからは、ホバーすると画像が3列のグリッドになるよう設定していきます。

スライダーには3個以上アイテムがありますが、ビューポートに表示されるのは最初の3個だけなので、他のアイテムにアニメーションをつける必要はありません。

プレースホルダーアイテムを正しく配置したら、実際のスライダーアイテムを下に配置し、プレースホルダーアイテムを削除しましょう。

アニメーション

まず、プレースホルダーアイテムが動く位置を計算します。プレースホルダーアイテムのleftの位置からスライダーアイテムのleftの位置を引くと、正しいx座標が割り出せます。y座標についても原理は同じで、lefttopに置き換えます。

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;

プレースホルダーアイテムはスライダーアイテムよりも小さいため、拡大する必要がありますよね。プレースホルダーアイテムの幅をスライダーアイテムの一つぶんの幅で割ると(すべて同じサイズなのでどれでも問題ありません)、正しい値が割り出せます。

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

コンテナ内画像の最初のx座標を設定するために、intersectX1 X2 X3を使いましょう。スライダー内の各画像には、それぞれx座標が設定されています。コンテナ内のスライダー画像にアニメーションをつけるためにx座標を使い、視差効果を作っていきます。

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

拡大時のアニメーション用に、新しいGSAPタイムラインを作成します。タイムラインが完了すると、setHoverAnimationが起動されます。その後、プレースホルダーアイテムがリセットされて、再度アニメーションが再生される準備が整う仕組みです。

なお、文字や閉じるボタンなどのスライダー要素にもアニメーションをつけますが、文字のアニメーションについては触れません。

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

3個のプレースホルダー画像は、先ほど定義したx座標とy座標に沿って動きます。GASPタイムラインを見てみましょう。

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. 視差効果を実装する

画像は「overflow:hidden」に設定したコンテナ内でスケールされ、スライダーを動かすと、ビューポート内の画像が若干異なるスピードで動きます。

マークアップ

<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>

アニメーション

Virtual Scrollを土台にしてスムーススクロールを作っていますが、ここではその詳細について触れません。補間を使うと、滑らかな視差効果スクロールが実装できます。

再度this.domオブジェクトを用意し、必要なDOM要素をすべて格納しましょう。

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');

スクロールの状況を把握するために、スライダーを動き具合を定義するシンプルなプログレスバーを作りました。これは、スライダーコンテナのx座標をあらわす0から1の間の値を計算します。

この値はrequestAnimationFrameで更新され、プログレスバーのscaleXの値のアニメーションに使われます。

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})`;

画像への視差効果のアニメーションをはじめる前に、まず各スライダーアイテムのデータをthis.itemsに格納します。この配列に含まれるのは、画像要素、境界線、画像のx座標です。

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

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

ここからは、いよいよ画像に視差効果をつけていきましょう。以下のコードはrequestAnimationFrame内のレンダリング関数で実行され、またスクロールの補間値であるscroll.state.lastを使用します。

パフォーマンスを向上させるために、ビューポートに表示されているアイテムを検出し、ビューポート内の画像だけにアニメーションをつけましょう。

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

アイテムがビューポート内に表示されている場合、対象要素がビューポートに表示されている割合を0から100(percentage)で計算します。

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

その値を保存したら、percentageに基づいて新しい値を計算し、以下のようにピクセル値に変換します。

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

最終的なx座標を計算したら、以下のようにコンテナ内の画像にアニメーションをつけましょう。

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

renderは以下のようになります。

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)`;
    }
  });
}

おわりに

視差効果スライダーを作るのは、それほど難しいことではありません。ぜひ本記事を参考に、オリジナルの視差効果スライダー制作にチャレンジしてみてください。

なお、今回例にしている視差効果スライダーは、Nieuw BergenのWebサイトに実装されています。

(執筆:Ruud Luijten 翻訳:Asuka Nakajima 編集:Saito Hayato 提供元:codrops

SHARE

  • 広告主募集
  • ライター・編集者募集
  • WorkshipSPACE
エンジニア副業案件
Workship