縦にスクロールすると、画面内のパネルが横へ流れるセクションは、外側を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画面に収まるか、拡大表示でも読み切れるかを確認してください。長文や入力フォームを載せる場合は縦並びを選ぶほうが、内容を追いやすくなります。