gsap

GSAP ScrollTriggerで横スクロールを作る|移動距離とcontainerAnimation

縦スクロールで複数パネルを横へ送るGSAPの実装例です。scrollWidthから表示幅を引く距離計算、containerAnimationによる内側の表示、スマホ・reduced-motionで縦並びへ戻す処理を解説します。

この記事の目次
  1. 横へ動かす距離は、列の全幅から1画面分を引く
  2. HTMLとCSSは、まず縦に読める形で作る
  3. 外側を固定し、内側の列を動かすJavaScript
  4. 内側の表示タイミングにはcontainerAnimationを渡す
  5. xPercentの例と混ぜないために、動かす対象を確認する
  6. 最後の位置と、縦並びへ戻る動作を確かめる

縦にスクロールすると、画面内のパネルが横へ流れるセクションは、外側をpinで固定し、内側の列をxで動かす構成で作れます。最後のパネルを表示位置へぴったり合わせるには、移動量を「列の全幅 − 表示領域の幅」で求めます。

この記事では、3枚のパネルを横に送り、2枚目以降のカードを入ってくる位置に合わせて表示します。900px未満の画面と「動きを減らす」設定では普通の縦並びに戻します。GSAP 3.15.0を使った、npmのimportに対応するプロジェクト向けの実装です。

横へ動かす距離は、列の全幅から1画面分を引く

幅1,440pxの表示領域に、同じ幅のパネルが3枚あるとします。列の全幅は4,320pxですが、最初の1枚はすでに表示されています。最後まで送るのに必要な移動量は4,320 − 1,440 = 2,880pxです。

const distance = () => track.scrollWidth - section.clientWidth;

この距離だけ列を左へ移動し、同じ距離を縦スクロール区間にも使います。今回のscrub: trueとease: 'none'の組み合わせでは、縦に1px進むごとに横にも1px進む関係になります。もっとゆっくり見せるなら、横移動量はそのままで、縦スクロール区間だけ長くできます。

幅1,440px・3枚の場合 値(px) 使い道
列の全幅 4,320 track.scrollWidthで測る
表示領域 1,440 section.clientWidthで測る
横移動 −2,880 左へ動かすので負の値
縦スクロール区間 2,880 end: '+=2880'に相当

endに全幅の4,320pxを指定しても、それだけで最後の位置がずれるわけではありません。ただし、同じ横移動に1画面分余計なスクロールが必要になります。xにも全幅を使うと、最後のパネルまで左へ通り過ぎてしまいます。

スポンサーリンク

HTMLとCSSは、まず縦に読める形で作る

GSAPを読み込みます。既存のViteなどのプロジェクトで、次を実行してください。

npm install gsap@3.15.0

HTMLは外側の.horizontal、横に動く.horizontal__track、各パネルという3層です。パネル内のカードは、列全体の横移動とは別に表示を変えます。

<section class="horizontal" aria-label="制作の3つの段階">
  <div class="horizontal__track">
    <section class="horizontal__panel">
      <div class="horizontal__card">
        <p>01 / PLAN</p><h2>伝えたいことを決める</h2>
        <p>最初のパネル。縦にスクロールすると、次の段階が右から現れます。</p>
      </div>
    </section>
    <section class="horizontal__panel">
      <div class="horizontal__card">
        <p>02 / BUILD</p><h2>形にして試す</h2>
        <p>横から入る位置に合わせて、このカードを表示します。</p>
      </div>
    </section>
    <section class="horizontal__panel">
      <div class="horizontal__card">
        <p>03 / REVIEW</p><h2>読みやすさを確かめる</h2>
        <p>最後のパネルを見終えると、続きの本文へ進みます。</p>
      </div>
    </section>
  </div>
</section>

次をhorizontal.cssへ保存し、ページから読み込みます。JavaScriptが使えなくてもスタイルが届くよう、HTMLのhead内で<link rel="stylesheet" href="./horizontal.css">を読み込む構成にできます。

.horizontal {
  width: 100%;
}
.horizontal__panel {
  box-sizing: border-box;
  min-height: 70svh;
  padding: clamp(24px, 5vw, 80px);
  display: grid;
  place-items: center;
  background: #dbeafe;
  color: #15233b;
}
.horizontal__panel:nth-child(2) { background: #dcfce7; }
.horizontal__panel:nth-child(3) { background: #fef3c7; }
.horizontal__card {
  width: min(100%, 40rem);
  overflow-wrap: anywhere;
}
.horizontal.is-enhanced { overflow: hidden; }
.horizontal.is-enhanced .horizontal__track { display: flex; }
.horizontal.is-enhanced .horizontal__panel {
  flex: 0 0 100%;
  min-width: 0;
  min-height: 100svh;
}

最初からdisplay: flexやopacity: 0を指定していない点がポイントです。横送りを有効にするときだけis-enhancedを付けるため、JavaScriptなしでも、動きを減らした場合でも、内容が縦に残ります。

等幅にする指定はflex: 0 0 100%です。パネルが縮まないようにし、box-sizing: border-boxでpadding込みの幅にしています。今回は列にgapや外側の左右paddingを付けていません。まずこの寸法が単純な状態で動作を合わせましょう。

外側を固定し、内側の列を動かすJavaScript

次をhorizontal.jsへ保存します。DOMが存在する状態でmountHorizontal()を呼ぶ構成です。

import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

export function mountHorizontal(section) {
  const track = section.querySelector('.horizontal__track');
  const panels = [...section.querySelectorAll('.horizontal__panel')];
  const media = gsap.matchMedia();

  media.add(
    {
      desktop: '(min-width: 900px)',
      reduceMotion: '(prefers-reduced-motion: reduce)',
    },
    ({ conditions }) => {
      if (!conditions.desktop || conditions.reduceMotion || panels.length < 2) return;

      section.classList.add('is-enhanced');
      const distance = () => Math.max(0, track.scrollWidth - section.clientWidth);
      const horizontalTween = gsap.to(track, {
        x: () => -distance(),
        ease: 'none',
        scrollTrigger: {
          trigger: section,
          start: 'top top',
          end: () => '+=' + distance(),
          pin: true,
          scrub: true,
          invalidateOnRefresh: true,
        },
      });

      // The first panel is already visible; reveal the following panels as they enter.
      panels.slice(1).forEach((panel) => {
        gsap.from(panel.querySelector('.horizontal__card'), {
          opacity: 0,
          y: 32,
          ease: 'none',
          scrollTrigger: {
            trigger: panel,
            containerAnimation: horizontalTween,
            start: 'left 85%',
            end: 'left 25%',
            scrub: true,
            invalidateOnRefresh: true,
          },
        });
      });

      return () => {
        section.classList.remove('is-enhanced');
        // This component owns the track's transform; leave no inline transform on fallback.
        track.style.removeProperty('transform');
      };
    },
  );

  const refresh = () => ScrollTrigger.refresh();
  if (document.readyState === 'complete') refresh();
  else window.addEventListener('load', refresh, { once: true });

  return () => {
    window.removeEventListener('load', refresh);
    media.revert();
  };
}

ページのエントリーとなるJavaScriptから呼び出します。type="module"のスクリプトなど、HTMLの解析後に実行される場所へ置いてください。

import { mountHorizontal } from './horizontal';

const section = document.querySelector('.horizontal');
const disposeHorizontal = mountHorizontal(section);

// SPAでこの画面を破棄するときに呼ぶ
// disposeHorizontal();

固定対象は外側のsectionで、xを変える対象は内側のtrackです。固定する要素自体を横へ動かさないよう、ここは分けてください。pinで作られる余白や固定位置の調整は、ScrollTriggerのpinの使い方で詳しく説明しています。

xとendは、どちらも関数から寸法を取得しています。幅変更後に再計算されるよう、Tween側にはinvalidateOnRefresh: trueを設定しました。固定の数値を一度だけ計算して渡すと、画面を広げたり狭めたりしたあとも古い距離で動いてしまいます。

内側の表示タイミングにはcontainerAnimationを渡す

この横移動では、ブラウザの横スクロール位置が変わっているわけではありません。列をtransformで動かしています。そこで内側のScrollTriggerにcontainerAnimation: horizontalTweenを渡し、親の横移動を基準に位置を判定させます。

コードでは、パネルの左端が画面幅の85%の位置に来たらカードの表示を始め、25%の位置に来たら完了させています。

指定 今回の意味
trigger: panel 位置を測るのはパネル
containerAnimation: horizontalTween 列を横へ送るTweenを基準にする
start: 'left 85%' パネルの左端が画面の右寄りに入った位置
end: 'left 25%' パネルの左端が画面の左寄りまで進んだ位置
scrub: true この区間の進み具合にカードの表示を合わせる

最初のパネルはすでに見えているため、panels.slice(1)で2枚目以降だけを対象にしています。また、位置を測るパネルへ別の横移動を重ねず、その中のカードを上下と透明度だけで変化させています。

GSAP公式のcontainerAnimationの注意事項にあるとおり、親の横移動はease: 'none'にします。containerAnimationを使う内側のScrollTriggerでは、pinとsnapは使えません。この例では固定を親だけで行っています。

カードを時間でフェードインさせるのでなく、スクロール量に応じて表示したり戻したりする例です。scrub: trueと数値による追従の違いを確認したい場合は、scrubでスクロールとアニメーションを同期する方法も参照してください。

xPercentの例と混ぜないために、動かす対象を確認する

横スクロールのサンプルには、xPercent: -100 * (パネル数 - 1)を使う書き方もあります。これは、表示幅と同じ幅の各パネルを動かす構成なら、3枚で各パネルを自分の幅の200%分ずらす計算です。

今回のように列をひとつ動かすコードへ、その値だけコピーすると、列自身の幅を基準にした割合へ変わります。特に列にwidth: 300vwなどを設定している場合は、想定する移動量と大きく違ってしまいます。

この実装では、列のscrollWidthを測ってpx単位のxに統一しています。パネル数や幅を変えたら、「何pxの列を、何pxの表示領域から、どれだけ送るか」を実測すると、割合の基準を取り違えずに済みます。

最後の位置と、縦並びへ戻る動作を確かめる

確認するときは、横に動いたかだけでなく、最後のパネルの右端が表示領域の右端へ合うかを見ます。今回のサンプルでは幅1,440pxと1,100pxで、移動距離がそれぞれ2,880pxと2,200pxになることをブラウザで確認しています。

症状 先に確認するところ
最後に大きな空白が出る xに全幅を使っていないか。表示幅を引いているか
最後までは合うが、横送り区間が長すぎる endの距離。全幅をそのまま指定していないか
画面幅を変えたあとだけ位置が合わない 関数値のx/endとinvalidateOnRefresh
2枚目のカードが入っても反応しない containerAnimationに、列を動かすTweenを渡しているか
スマホで文字が透明のまま残る CSSで最初から隠していないか。メディア条件の解除でTweenが戻っているか

画像やフォントの読み込み後に寸法が変わった場合は、ScrollTrigger.refresh()で再計算します。サンプルはページのload時に実行しますが、その後の非同期コンテンツ追加は別です。追加やレイアウト変更が終わった時点で呼んでください。再計算の動作はrefreshの公式リファレンスで確認できます。

画面が900px未満になった場合や、OSの「動きを減らす」が有効になった場合は、gsap.matchMedia()が作成したTweenとScrollTriggerを戻します。自分で付けたis-enhancedと、列の移動用transformも解除し、縦に読める状態へ戻します。このコンポーネントでは列のtransformを専用に使い、別の演出から同じプロパティを操作しない前提です。

パネルは短い説明を見せる用途に向きます。文章を増やしたときは、固定中の1画面に収まるか、拡大表示でも読み切れるかを確認してください。長文や入力フォームを載せる場合は縦並びを選ぶほうが、内容を追いやすくなります。

スポンサーリンク