まずは下準備
JavaScriptのコードの解説を行う前に、まずはHTMLとCSSで事前準備を整えておきましょう。
パララックス(視差)効果を適用させたい要素(ここでは画像を含むdiv要素)と、画面をスクロールできるように高さを与えるための要素(スペーサー)を2つ用意します。
以下、HTMLとCSSのコードサンプルです。
box-sizingは全ての要素でborder-boxであるものとします。
<div class="spacer">Spacer</div>
<div class="parallax" id="animate">
<img src="img/coffee-image.webp"
width="250"
height="350"
alt="コーヒーの画像">
</div>
<div class="spacer">Spacer</div>
.spacer {
position: relative;
height: 80vh;
display: grid;
place-items: center;
}
.spacer::before {
content: "";
position: absolute;
height: 45%;
width: 1px;
background-color: black;
top: 0;
left: 0;
right: 0;
margin: auto;
}
.spacer::after {
content: "";
position: absolute;
height: 45%;
width: 1px;
background-color: black;
bottom: 0;
left: 0;
right: 0;
margin: auto;
}
.parallax {
position: relative;
width: fit-content;
margin: 0 auto;
}
.parallax img {
display: block;
}
要素の位置をずらしたり、ずらす距離を調整したりするのは、JavaScriptの方で実行・管理できるようにしておきたいので、img要素を含むdiv要素(.parallax)の位置調整に関しては、 position: relative のみ指定しておきます。
スペーサーの高さは80vhとしていますが、高さが変わっても大丈夫なように設計してあるのでお好みで調整してOKです。
現時点ではまだJavaScriptコードを書いていないので、当然パララックス効果は起こらず、ただ単に画像が配置されているだけになります。(下の表示結果を参照)
スクロール量を計測したり、スクロールを監視したりするのはJavaScriptの役割となるので、ここから先はJavaScriptでプログラムを組んでいく必要があります。
ただし、最近(2023年8月現在)では、animation-timeline: scroll()といった、新しいCSS functionが登場してきているので、もしかしたら近い将来、CSSだけでスクロールに合わせたアニメーションを実装できるようになるかもしれません。
ですが、scroll()は2023年8月1日時点で、Chromeなどごく一部のブラウザにしか対応していないので、もうしばらくはJavaScriptの力を借りなければ実装が難しい状況が続くでしょう。
それに、ハンバーガーメニューなどにも言えることなのですが、無理にCSSだけで作ろうとするよりJavaScriptで制御した方が楽な場合は多いです。
なので、CSSでできるようになったとしても、JavaScriptで実装する方法を押さえておいて損はないかと思います。
少し話が逸れましたが、パララックス効果を実装する上で重要となるのは、効果を適用させたい要素の位置とスクロール量の関係を把握することです。
次のステップでは、要素の位置やスクロール量を調べるためのJavaScriptのプロパティやメソッドをご紹介していきます。
POINT!
- ・動作のテストを行えるよう、スペーサーを配置してスクロールできるだけの高さを設定しておこう!
- ・要素の位置調整はJavaScriptの方で制御するので、positionプロパティの指定のみでOK!
- ・今後CSSが進化してCSSだけで実装可能になったとしても、JavaScriptで制御する方法を知っておいて損はない!
アニメーション発火のタイミング
パララックス効果を実装するには、適切なタイミングでアニメーション(要素の移動)が起こるようにしなければなりません。
そのためにまず、『いつアニメーション(パララックス)を発火させれば良いか?』を確認しておく必要があります。
アニメーションを発火さなければならないタイミングは、『スクロールによって、画面(ビューポート)内に効果を適用する要素が現れる時点』ですよね。
そのタイミングを把握するには、ページが開かれた際に次の3つの数値を得る必要があります。
- ・ビューポートの高さ
- ・ビューポートの上端からアニメーション適用要素までの距離
- ・ページが開かれた時点でのスクロール量
これらを把握することで、どれだけスクロールさせたら要素がビューポート内に現れるか=アニメーションの発火(開始地点)を知ることができます。
文章では少しイメージしにくいので、求めたい距離を表した図を見ていきましょう。
まずは、ページが開かれた際にページのトップに位置している(スクロール量がゼロである)場合です。
求めたい距離(スクロールされていない場合)
この場合、ビューポートの上端から要素までの距離から、ビューポートの高さを引くことで、求めたい距離(=アニメーションの発火に必要なスクロール量)を求めることができます。
通常であれば、新しくページが開かれたらこのようにページのトップに位置する(スクロール量=0)ので、ページが開かれた時点でのスクロール量を計測する必要はないように思えます。
しかし、ページがスクロールされた後で更新ボタンが押され、ページの途中から読み込まれる…というケースもあり得なくはないですよね。
この場合、既にスクロールされているスクロール量を考慮する必要があるので、求めたい距離を図で表すと下のようになります。
求めたい距離(スクロールされている場合)
この場合、ビューポートの上端から要素までの距離(2)と、スクロールされた量(3)を足したものから、ビューポートの高さを引く必要があります。
一見複雑そうに見えますが、要はbody要素の上端からパララックス効果を適用させたい要素までの距離から、ビューポートの高さを引けば良いわけです。
offsetTopプロパティを利用すれば、スクロール量を測らずとも常にbody要素の上端から任意の要素までの距離を測れますが、offsetTopは祖先要素のposition指定の影響を受けるので扱いづらい面があります。
というわけで、今回はスクロール量とビューポートの上端から要素までの距離を利用します。
では、そのスクロール量やビューポートの上端から要素までの距離などの情報は、どうやって取得できるのでしょうか?
JavaScriptには様々な読み取りプロパティが用意されていますが、今回は主に次の3つを使用します。
- ・ビューポートの高さ = window.innerHeight
- ・ビューポートの上端から要素までの距離 = Element.getBoundingClientRect().top
- ・ページが開かれた時点でのスクロール量 = window.scrollY
図で表すと次のようになります。
読み取りプロパティ図
これらの読み取りプロパティは、今回実装するパララックス効果をはじめ、スクロールに合わせたアニメーションを実装したい場合によく利用するので、覚えておくと何かと便利かと思います。
POINT!
- ・パララックス効果の実装においては、要素がビューポート内に現れる時点でアニメーションを発火させる必要がある!
- ・そのタイミング(距離)を把握するためには、body要素の上端~効果を適用させたい要素までの距離から、ビューポートの高さを引けば良い!
- ・ページが開かれた時点での、スクロール量・ビューポートの上端から要素までの距離、ビューポートの高さを取得すればOK!
スクロールに合わせて移動させる
パララックス効果を実装するために必要な情報を得る方法がわかれば、あとはスクロールイベントを監視して、スクロール量と要素の位置関係を考慮しながら要素を移動させていくだけです。
ですが、『移動させていくだけ』とは言っても、スクロール量に合わせた位置移動の微妙な調整は少しややこしい計算式を伴います。
なので、スクロールイベントを追加する前に、まずページが開かれた時点で実行される部分のコードから先に確認していきましょう。
const element = document.getElementById('animate');
// 要素の位置をずらしておく距離
const shiftDistance = 100;
element.style.top = `${shiftDistance}px`;
// ページが開かれた時点での要素のViewportの上端から要素までの距離を得る
const initialElementPosition = element.getBoundingClientRect().top;
// initialElementPosition + window.scrollY は、body要素の上端から要素までの距離
// そこからウィンドウの高さを引くことでアニメーションが発火するスタート地点を計測
const startScrollPosition = (initialElementPosition + window.scrollY) - window.innerHeight;
コードの説明はコメントの通りです。
今回は、位置をずらしておく距離を100(px)にしています。
よって、スクロールイベントで要素の移動距離を調整するためには、100から少しずつ数値を引けば良い(ただし、引いた結果が0を超えてはならない)ということになります。
また、100から少しずつ数値を引けば良いと言っても、ただ単に引く数値の大きさをどんどん増やしていけば良いというわけにはいきません。
なぜならば、スクロールを戻した(上方向にスクロールさせた)場合は、要素を移動させる距離も元に戻す(引く数値を小さくする)必要があるからです。
なので、100から引く数値を格納する変数を宣言しておき、スクロールイベントでその数値をプラスしていくような方法は採用できません。(これではスクロールさせる方向に関係なく、常に要素が上に移動してしまいます)
今回、スクロールに合わせて要素の位置と移動距離を調整したいわけですから、計算式に縦方向のスクロール量であるwindow.scrollYを含める必要があります。
そうすれば、下方向にスクロールさせた時は要素が上に移動し、上方向にスクロールさせた時は要素が下に移動する(元々ずらしておいた位置に戻っていく)ようになります。
ただし、アニメーションが発火するまでのスクロール量まで含めてしまうと調整が困難になってしまうので、アニメーション発火までのスクロール量(startScrollPosition)を引いておく必要があります。
以上を踏まえて、完成一歩手前のコードと表示結果を見てみましょう。
const element = document.getElementById('animate');
// 要素の位置をずらしておく距離
const shiftDistance = 100;
element.style.top = `${shiftDistance}px`;
// ページが開かれた時点での要素のViewportの上端から要素までの距離を得る
const initialElementPosition = element.getBoundingClientRect().top;
// initialElementPosition + window.scrollY は、body要素の上端から要素までの距離
// そこからウィンドウの高さを引くことでアニメーションが発火するスタート地点を計測
const startScrollPosition = (initialElementPosition + window.scrollY) - window.innerHeight;
// スクロールイベントを監視し、animateElement関数を実行
window.addEventListener('scroll', () => requestAnimationFrame(animateElement));
// 要素をスクロールに合わせて移動させる関数
function animateElement() {
// アニメーション開始地点までスクロール量が達しているかどうかを確認
if(window.scrollY > startScrollPosition) {
// スクロール量に応じて位置を調整
element.style.top = `${Math.max(0, (shiftDistance - (window.scrollY - startScrollPosition) / 2))}px`;
}
}
// スクロールされていない状態であれば、スクロールイベントが発生しないので
// 位置を調整しておく
if(window.scrollY === 0) {
requestAnimationFrame(animateElement);
}
コードには細かくコメントを加えていますが、解説が足りない部分を補足していきます。
要素の位置調整を行うのに、Math.max()メソッドを使っていますが、これは引数のうち最大の値を返すものです。
Math.max()の引数に0を与えておけば、計算式が負の値になったら0の方が採用されるので、topプロパティの値がマイナスになることはなくなります。
なお、(window.scrollY – startScrollPosition)を2で割っているのは、アニメーションの移動距離調整のためです。
次の章で重要なポイントとなりますが、大きな値で割るほど移動距離はゆっくり(小刻み)になり、小さな値で割ると移動距離は早く(大まかに)なります。
また、最初にずらしておく距離を100(px)としているものの、スペーサーの高さによっては移動可能な距離が100pxに満たなくなる場合があります。
スクロール量が0でない(少しでもスクロールされている)場合は、scrollイベントが発生するのでその時点で適切な位置に要素が移動しますが、スクロール量が0の場合はscrollイベントが発生しません。
そのため、スクロール量が0である場合に一度アニメーションの関数を実行して、適切な位置に要素が移動するようにしています。
さて、一応は目標とするような動きを実現できましたし、これで完成としても良いのですが…。
このままではスクロール量に関わらず移動距離が一定なので、少し違和感を感じるものとなっています。
そこで、次のステップでは最後の仕上げとして、『最初は大きく移動して、徐々に移動距離が細かくなっていく』ようなアニメーションにしてみましょう。
POINT!
- ・要素の移動距離は、scrollYの値を使って調整しよう!
- ・Math.max()は、引数のうち最も大きな値を返す!
- ・ページが読み込まれた時に、スクロール量が0でなければscrollイベントが発生する!
移動に緩急をつける
より自然な動きにするには、アニメーションに緩急をつけると効果的です。
CSSアニメーションであれば、transition-timing-functionの値をease-in-outとかに設定すれば簡単に緩急をつけることができますが、今回はJavaScriptで移動距離を計算することによってアニメーションが成り立っているので、そうはいきません。
今回の場合で言えば、値が一定に増えていくのではなく、徐々に増え幅が小さくなるような計算式を作り上げる必要があります。
グラフで表すと次のようなイメージになります。
増加幅が徐々に小さくなっているグラフ
そして、このように増減幅が減っていくグラフを描くことで有名なのが対数関数(y=log2xなど)です。
y=log2xならば、『2をy乗すればxになる』という意味になりますが、ここでは対数関数に関する詳しい理解までは必要ありません。
とにかく、対数関数を使えば増加幅が徐々に減っていく値を利用できるということを押さえておけばOKです。
前章のコードでは、要素の移動距離を調整するために(window.scrollY – startScrollPosition)を2で割りましたが、この2(分母)を、対数関数を使って増加幅に緩急をつければ、必然的にアニメーションにも緩急がつくことになります。
JavaScriptで対数関数を利用するには、Math.log()を使います。
Math.log()は、自然数eを底とした 数値の自然対数を返しますが、引数の数値が大きくなればなるほど、返す値の増加幅は小さくなると思っておけばOKです。
たとえば、Math.log(20)の結果は約3ですが、引数を倍(Math.log(40))にした時の結果は約3.69、引数を3倍(Math.log(60))にした時の結果は約4.1です。
引数は20ずつ一定の割合で増加しているのに対し、Math.log()で返される結果の増加幅は0.69→0.41となっており、増加幅が小さくなっていることがわかりますね。
注意点としては、Math.log(0)は-Infinityを返してしまうので、Math.log()の引数が0にならないように気をつける必要があります
以上を踏まえて、完成形のコードと表示結果を確認していきましょう。
const element = document.getElementById('animate');
// 要素の位置をずらしておく距離
const shiftDistance = 100;
element.style.top = `${shiftDistance}px`;
// ページが開かれた時点での要素のViewportの上端から要素までの距離を得る
const initialElementPosition = element.getBoundingClientRect().top;
// initialElementPosition + window.scrollY は、body要素の上端から要素までの距離
// そこからウィンドウの高さを引くことでアニメーションが発火するスタート地点を計測
const startScrollPosition = (initialElementPosition + window.scrollY) - window.innerHeight;
// スクロールイベントを監視し、animateElement関数を実行
window.addEventListener('scroll', () => requestAnimationFrame(animateElement));
// 要素をスクロールに合わせて移動させる関数
function animateElement() {
// アニメーション開始地点までスクロール量が達しているかどうかを確認
if(window.scrollY > startScrollPosition) {
// スクロール量に基づいて分母を調整
let denominator = Math.max(1, Math.log(window.scrollY - startScrollPosition + 1));
// スクロール量に応じて位置を調整
element.style.top = `${Math.max(0, (shiftDistance - (window.scrollY - startScrollPosition) / denominator * 2.25))}px`;
}
}
// スクロールされていない状態であれば、スクロールイベントが発生しないので
// 位置を調整しておく
if(window.scrollY === 0) {
requestAnimationFrame(animateElement);
}
修正により、スクロール量が少ないうちは要素が大きく移動し、スクロール量が増すにつれて移動距離が短くなりました!
アニメーションがより自然で、滑らかな印象になったことが確認できるかと思います。
ちなみにですが、分母(denominator )が0にならないように、Math.max()で最小でも1になるよう保険をかけています。
アニメーションの指定においては、このように最小値の下限を設定しておきたい場合が多いので、Math.max()が大活躍してくれます。
今回ご紹介したように、対数関数やサイン・コサインなどの数学を扱う関数を利用することで、アニメーションのトランジションやタイミングを細かくコントロールすることが可能となるので、Mathオブジェクトの基本的なメソッドを把握しておくと何かと役に立ちます。
POINT!
- ・対数関数は、徐々に増加幅が小さくなるグラフを描く!
- ・Math.log()は、eを底とした数値の自然対数を返す!
- ・アニメーションを細かく制御したい場合、Mathオブジェクトのメソッドを上手に利用しよう!