Workship MAGAZINE書籍化第3弾!#ADHDフリーランス の新常識 他
- 隔週月曜更新!フリーランス・副業ニュース
この記事では、HTMLによってviewpoint内の画像に少しのアニメーションをくわえ、Webサイトにスムーズなスクロール効果を追加する方法をご紹介します。
ここで言う「スムーズなスクロール」とは、ある要素までクリックひとつでスムーズに移動することではなく、画像のアニメーションによるスムーズなスクロール効果のことを指します。
画像によるスムーズなスクロール効果を、Webページに実装するメリットは何でしょうか?
これまでにスクロールのアニメーションを実装したことがある人なら、ブラウザでコンテンツを表示した時に「ページがガタつく」のを経験したことがあるかもしれません。とくに画像の場合、スクロール時にページがカクカクすることがよくあります。
これでは見た目も使い心地も、良いとはいえません。
この問題を解決するために「本来の」スクロール機能を使う代わりに、画像コンテンツ自体にスクロールと連動したアニメーションの仕掛けを取り入れるのです。
Jesper Landbergによる、Codepenのデモをひとつ見てみましょう。
See the Pen Twotwentytwo.se – smooth scroll with skew effect by jesper landberg (@ReGGae) on CodePen.
こちらの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を呼び出します。要素は上向きに動くので、値がマイナスになることに注意してください。
レイアウトの変更には以下が必要です。
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は以下のように機能します。
つまり、[ウィンドウの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-skaya、Ada Sokół、Rafal Bojarなどがあります。とくにRafal Bojarのサイトでは、スクロールとシンクロした美しい画像アニメーションが実装されているので必見です。
今回紹介したコードを参考に、ぜひWebサイトにスムーズなスクロール効果を取り入れてみましょう。
(原文:Mary Lou 翻訳:Tamura Yui)
こちらもおすすめ!▼
【リヴィール入門】オーバーレイのページ遷移を作ってみよう。下に隠れたコンテンツをスマートに表示する
Workship MAGAZINE
チュートリアルをなぞるだけ!Three.jsでインタラクティブエフェクトを作ろう
Workship MAGAZINE
react-three-fiberを使ってSVGをバラバラに動かしてみる
Workship MAGAZINE