画像にアニメーションを加えて、Webページのスクロールを滑らかにしてみる

DESIGNER

内部アニメーション

この記事では、HTMLによってviewpoint内の画像に少しのアニメーションをくわえ、Webサイトにスムーズなスクロール効果を追加する方法をご紹介します。

ここで言う「スムーズなスクロール」とは、ある要素までクリックひとつでスムーズに移動することではなく、画像のアニメーションによるスムーズなスクロール効果のことを指します。

なぜ画像にスクロールを滑らかにすべきなのか

画像によるスムーズなスクロール効果を、Webページに実装するメリットは何でしょうか?

これまでにスクロールのアニメーションを実装したことがある人なら、ブラウザでコンテンツを表示した時に「ページがガタつく」のを経験したことがあるかもしれません。とくに画像の場合、スクロール時にページがカクカクすることがよくあります。

これでは見た目も使い心地も、良いとはいえません。

この問題を解決するために「本来の」スクロール機能を使う代わりに、画像コンテンツ自体にスクロールと連動したアニメーションの仕掛けを取り入れるのです。

スムーズなスクロール効果をくわえる方法

Jesper Landbergによる、Codepenのデモをひとつ見てみましょう。

See the Pen Twotwentytwo.se – smooth scroll with skew effect by jesper landberg (@ReGGae) on CodePen.0

こちらのskew効果を使ったスムーズなスクロールのデモでは、スクロール時に画像にskew効果(画像を斜めらせる効果)を与える方法が紹介されています。

ここではコンテンツのwrapperをfixedポジションに設定し、overflowをhiddenにして子要素を動かすという仕組みになっています。bodyは設定したコンテンツのheightを取得するので、スクロールバーはそのまま保たれます。

スクロールすると、内部のコンテンツにアニメーション効果が与えられますが、fixedに設定したwrapperはその場に固定されます。この仕掛けにより、シンプルでありながら効果的な、スムーズなスクロール効果が実現できるのです。

なお記事の先頭で示したデモの場合は、以下のような構造になっています。


<body class="loading">
	<main>
		<div data-scroll>
			<!-- ... --->
		</div>
	</main>
</body>

main要素にはfixedまたはstickyを設定し、data-scrollのdiv要素が移動するようにしましょう。

画像にスクロールと連動したアニメーションをくわえる方法

記事内の画像アニメーションには、画像のほかに、overflowを”hidden”に設定した親要素のcontainerが必要です。

ここでの狙いは、スクロール時に画像を上下に動かすことです。div要素で背景画像を設定し、overflowのサイズをコントロールしやすくしましょう。

このとき、画像のdiv要素は親要素よりも大きくなるようにしてください。以下がそのコードです。


<div class="item">
<div class="item__img-wrap"><div class="item__img"></div></div>
<!-- ... --->
</div>

次に、これらの要素のスタイルを見ていきましょう。

heightの代わりにpaddingを使うことで、背景画像を含む内部のdiv要素に正しいアスペクト比を設定できます。これにはアスペクト比変数を使います。

単純に画像のwidthとheightを設定し、計算はスタイルシートに任せましょう。よくわからない場合はCSS-TricksのApect Ratio Boxesを読み、アスペクト比と他のテクニックについて学ぶのがおすすめです。

item__img-wrapクラスの背景画像に画像変数を設定すれば、たくさんのルールを書かなくてよくなります。もちろん、変数に対応していない古いブラウザでも表示させたい場合は、これをする必要はありません。背景画像としてitem__imgに直接設定してください。


.item__img-wrap {
--aspect-ratio: 1/1.5;
overflow: hidden;
width: 500px;
max-width: 100%;
padding-bottom: calc(100% / (var(--aspect-ratio)));
will-change: transform;
}
.item:first-child .item__img-wrap {
--aspect-ratio: 8/10;
--image: url(https://tympanus.net/Tutorials/SmoothScrollAnimations/img/1.jpg);
}
.item:nth-child(2) .item__img-wrap {
width: 1000px;
--aspect-ratio: 120/76;
--image: url(https://tympanus.net/Tutorials/SmoothScrollAnimations/img/2.jpg);
}
...

スクロール時に上下に動かしたいのは背景画像を含むdiv要素なので、これを親要素よりも大きくしなければなりません。そのためには、heightとtopの計算に使う”overflow”変数を定義します。

この変数を設定するのは、異なるクラスでも簡単に変更できるようにするためです。そうすることでそれぞれの画像に異なるoverflowを設定でき、少しずつ違う視覚効果を演出できます。


.item__img {
--overflow: 40px;
height: calc(100% + (2 * var(--overflow)));
top: calc( -1 * var(--overflow));
width: 100%;
position: absolute;
background-image: var(--image);
background-size: cover;
background-position: 50% 0%;
will-change: transform;
}
.item__img--t1 {
--overflow: 60px;
}
.item__img--t2 {
--overflow: 80px;
}
.item__img--t3 {
--overflow: 120px;
}

次はJavaScriptです。ヘルパーメソッドと変数をいくつか見ていきましょう。


const MathUtils = {
// [a, b] から [c, d] の範囲で数字xをマップする
map: (x, a, b, c, d) => (x - a) * (d - c) / (b - a) + c,
// 線形補間
lerp: (a, b, n) => (1 - n) * a + n * b
};
const body = document.body;

後の計算のために、画面サイズとheightを取得する必要があります。


let winsize;
const calcWinsize = () => winsize = {width: window.innerWidth, height: window.innerHeight};
calcWinsize();

リサイズ時に、この値を再計算する必要もあります。


window.addEventListener('resize', calcWinsize);

またページをどれだけスクロールするかも把握しなければなりません。


let docScroll;
const getPageYScroll = () => docScroll = window.pageYOffset || document.documentElement.scrollTop;
window.addEventListener('scroll', getPageYScroll);

これでヘルパー機能の準備ができたので、次はメインの機能を実装しましょう。

まずは、スムーズなスクロール機能のクラスを作成します。


class SmoothScroll {
constructor() {
this.DOM = {main: document.querySelector('main')};
this.DOM.scrollable = this.DOM.main.querySelector('div[data-scroll]');
this.items = [];
[...this.DOM.main.querySelectorAll('.content > .item')].forEach(item => this.items.push(new Item(item)));
...
}
}
new SmoothScroll();

ここまでmain要素(”sticky”に設定したcontainer)とスクロール要素(スクロールを装って移動させるもの)を扱ってきました。

さらに、itemのインスタンスの配列を作成する必要があります。これについては後ほど解説します。

次はスクロール時にtranslateYの値を変更する設定をしますが、ここでscaleやrotationなどの他のプロパティも一緒に変更します。

この構成を格納したオブジェクトを作成しましょう。ここではただtranslationYを設定しておきます。


constructor() {
...
this.renderedStyles = {
translationY: {
previous: 0,
current: 0,
ease: 0.1,
setValue: () => docScroll
}
};
}

スムーズなスクロール効果を実現するには、インターポレーション(補間)を利用します。

”previous”と”current”の値が補間する値です。currentのtranslationYは、特定のインクリメントにおいてこれら2つの値のあいだにある値です。”ease”は補完する量です。

以下の式で現在の移動値を計算できます。


previous = MathUtils.lerp(previous, current, ease)

関数setValueは、この場合では現在のスクロール位置となる現在の値を設定します。

ページロード時にこれが実行されるようにし、正しいtranslationY値を設定しましょう。


constructor() {
...
this.update();
}
update() {
for (const key in this.renderedStyles ) {
this.renderedStyles[key].current = this.renderedStyles[key].previous = this.renderedStyles[key].setValue();
}
this.layout();
}
layout() {
this.DOM.scrollable.style.transform = `translate3d(0,${-1*this.renderedStyles.translationY.previous}px,0)`;
}

両方の補間値(この場合はスクロール値)を同じくし、アニメーションなしでも直ちに移動が行われるようにします。そのためには、ページをスクロールした時にアニメーションが実行されればいいのです。

その後は、要素に変形を適用する関数layoutを呼び出します。要素は上向きに動くので、値がマイナスになることに注意してください。

レイアウトの変更には以下が必要です。

  • main要素のpositionをfixedに、overflowをhiddenに設定。画面に固定しスクロールされないようにする
  • ページのスクロールバーをキープするために、bodyのheightを設定。これでスクロール要素のheightと等しくなる

constructor() {
...
this.setSize();
this.style();
}
setSize() {
body.style.height = this.DOM.scrollable.scrollHeight + 'px';
}
style() {
this.DOM.main.style.position = 'fixed';
this.DOM.main.style.width = this.DOM.main.style.height = '100%';
this.DOM.main.style.top = this.DOM.main.style.left = 0;
this.DOM.main.style.overflow = 'hidden';
}

また、リサイズ時のbodyのheightもリセットする必要があります。


constructor() {
...
this.initEvents();
}
initEvents() {
window.addEventListener('resize', () => this.setSize());
}

次に、スクロール時に値を変更するループ機能を実装します。


constructor() {
...
requestAnimationFrame(() => this.render());
}
render() {
for (const key in this.renderedStyles ) {
this.renderedStyles[key].current = this.renderedStyles[key].setValue();
this.renderedStyles[key].previous = MathUtils.lerp(this.renderedStyles[key].previous, this.renderedStyles[key].current, this.renderedStyles[key].ease);
}
this.layout();
// すべてのitemに対して
for (const item of this.items) {
// itemがviewport呼び出しの中にある場合は関数render
// これにより、ドキュメントのスクロール値とviewport内のitemの位置に基づいて、itemの内部画像の移動値が変更される
if ( item.isVisible ) {
item.render();
}
}
// ループ
requestAnimationFrame(() => this.render());
}

ここで新しく登場するのが、itemの関数renderの呼び出しです。これはviewport内のすべてのitemに対して呼ばれます。これにより、itemの内部画像の移動値が変更されます。

スクロール要素のheightに依存していることから、事前に画像をロードしてレンダリングすることで、正しいheightの値を計算できます。

ここではimagesLoadedを使用しています。


const preloadImages = () => {
return new Promise((resolve, reject) => {
imagesLoaded(document.querySelectorAll('.item__img'), {background: true}, resolve);
});
};

画像がロードされたらページローダーを削除し、スクロール位置を取得し、SmoothScrollインスタンスを初期化します。なお、最後の更新前にページをスクロールした場合、この値は0にならない場合があります。


preloadImages().then(() => {
document.body.classList.remove('loading');
// スクロール位置を取得
getPageYScroll();
// Smooth Scrollingを初期化
new SmoothScroll(document.querySelector('main'));
});

これでSmoothScrollができました。次はそれぞれのページitem(画像)を表すクラスItemを作成しましょう。


class Item {
constructor(el) {
this.DOM = {el: el};
this.DOM.image = this.DOM.el.querySelector('.item__img');
this.renderedStyles = {
innerTranslationY: {
previous: 0,
current: 0,
ease: 0.1,
maxValue: parseInt(getComputedStyle(this.DOM.image).getPropertyValue('--overflow'), 10),
setValue: () => {
const maxValue = this.renderedStyles.innerTranslationY.maxValue;
const minValue = -1 * maxValue;
return Math.max(Math.min(MathUtils.map(this.props.top - docScroll, winsize.height, -1 * this.props.height, minValue, maxValue), maxValue), minValue)
}
}
};
}
...
}

ここでのロジックは、クラスSmoothScrollと全く同じです。

変更したいプロパティを含むオブジェクトrenderedStylesを作成します。ここではY軸のitemの内部画像(this.DOM.image)を移動します。

ここで追加するのは、移動の最大値の定義です(maxValue)。この値は先程CSS変数で設定したものです(overflow)。また、移動の最小値は-1*maxValとなると想定します。

関数setValueは以下のように機能します。

  • itemのtop値(viewportに対してrelative)がウィンドウのheight(viewportに入ったばかりのitem)と同じになった時、移動値は最小値になる
  • itemのtop値(viewportに対してrelative)が「-itemのheight」(viewportを出たばかりのitem)と同じになった時、移動値は最大値になる

つまり、[ウィンドウのheight, -itemのheight]から[minVal, maxVal]の範囲でitemのtop値(viewportに対してrelative)をマッピングしているのです。

次にロード時の初期値を設定します。上記で解説した機能を適用するにはitemのheightとtopが必要になるので、これも計算できます。


constructor(el) {
...
this.update();
}
update() {
this.getSize();
for (const key in this.renderedStyles ) {
this.renderedStyles[key].current = this.renderedStyles[key].previous = this.renderedStyles[key].setValue();
}
this.layout();
}
layout() {
this.DOM.image.style.transform = `translate3d(0,${this.renderedStyles.innerTranslationY.previous}px,0)`;
}
getSize() {
const rect = this.DOM.el.getBoundingClientRect();
this.props = {
height: rect.height,
top: docScroll + rect.top
}
}

ウィンドウがリサイズされたときも、同様のものが必要になります。


initEvents() {
window.addEventListener('resize', () => this.resize());
}
resize() {
this.update();
}

ここでは、SmoothScrollのrenderループ関数(requestAnimationFrame)内で呼ばれる関数renderを定義する必要があります。


render() {
for (const key in this.renderedStyles ) {
this.renderedStyles[key].current = this.renderedStyles[key].setValue();
this.renderedStyles[key].previous = MathUtils.lerp(this.renderedStyles[key].previous, this.renderedStyles[key].current, this.renderedStyles[key].ease);
}
this.layout();
}

上で述べたように、これはviewport内のitemにのみ実行されます。IntersectionObserver APIを使ってこれを実現できます。


constructor(el) {
...
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => this.isVisible = entry.intersectionRatio > 0);
});
this.observer.observe(this.DOM.el);
...
}

これでスムーズなスクロール効果が追加できました!

まとめ

このチュートリアルは役に立ちましたか?

このようなスムーズなスクロール効果を使ったWebサイトの例として、Elena Iv-skayaAda SokółRafal Bojarなどがあります。とくにRafal Bojarのサイトでは、スクロールとシンクロした美しい画像アニメーションが実装されているので必見です。

今回紹介したコードを参考に、ぜひWebサイトにスムーズなスクロール効果を取り入れてみましょう。

(原文:Mary Lou 翻訳:Tamura Yui)



こちらもおすすめ!▼

SHARE

  • 広告主募集
  • ライター・編集者募集
  • WorkshipSPACE
デザイナー副業案件
Workship