スクロールパラパラのつくり方

スクロールでパラパラまんがする「スクロールパラパラ」の仕組みと、つくり方についてのまとめです:)

パラパラの仕組み

まずはHTMLの準備から。
下記のように、ul要素の中に空っぽのli要素を、パラパラまんがのコマの数分(ここでは20コマ)並べて、li要素には固有のclass名を付けておきます。ここではf01f20という風に連番で名付けましたfはframeの「f」)

<body id="scpr">
<ul>
	<li class="f01"></li>
	<li class="f02"></li>
	<li class="f03"></li>
	︙
	<li class="f20"></li>
</ul>
</body>

続いてCSSで、画像を表示させます。
アニメーションの元となるイラストは、img要素ではなくて、li要素に背景画像として表示させます。
backgroundプロパティの最後の値fixedは、background-attachmentプロパティの指定。これで、背景画像をブラウザ画面の表示領域ビューポートに対して固定配置しています。

#scpr li {
	height: 240px;
	margin: 0;
	background: #f5f6f8 center / 400px no-repeat fixed;
}

li要素はスクロールによって上下に移動するけれど、背景画像だけはブラウザ画面の中央に固定配置されているので、まるで窓の向こうにイラストが見えてるみたいな効果になるんですねー:o

li要素の枠内から、それぞれ違うイラストを覗いてる

li要素には、それぞれ違うイラストが背景画像として指定されていて、そのイラストが見えるのは、そのli要素の中だけなので、li要素が通り過ぎるたびに、イラストが次々切り替わっているように見えるってわけです;)

けれど、スマホで見ると、ちっともパラパラしませんねぇ…。なんとスマホでは、background-attachmentプロパティfixedに対応していないみたいなんです…⁑(

Can I use... Support tables for HTML5, CSS3, etc - CSS background-attachment

background-attachmentプロパティのサポート状況(2019年2月現在)

Firefox for Androidは問題なく動いてるんですけれど、Android Browserではイラストが縦に並んじゃってるし、iPhoneではSafariでもChromeでもbody要素の中央あたりに固定配置されちゃってるみたい…X|
Chrome for Android大丈夫Supportedだと書いてあるけれど、PixelのChromeでもイラストが縦に並んじゃってるんですよね…:((2019年2月現在)

Xperia Firefox(左図)、iPhone Safari(中央)、Pixel Chrome(右図)

スマホでもパラパラしたい

背景画像を画面に固定配置できないとなると、どうしたものか¦‹background-attachment: fixedの代わりに、positionプロパティfixedを使えば、同じことができそうな気がします…!

要素をブラウザ画面に固定する

background-attachment: fixed背景画像を固定配置するのに対して、position: fixed要素自体を、ブラウザ画面に固定配置します。
li要素に指定すれば、li要素ごと画面に固定されてしまって、スクロールどころではなくなってしまうので、ここでは、li要素の擬似要素を、画面全面を覆うように固定配置して、そこに背景画像を指定しておきます。
さらに、li要素にoverflow: hiddenを指定すれば、擬似要素をli要素の形にトリミングできるんじゃないでしょうか:D

overflow: hiddenでハミ出た部分を非表示に
#scpr li {
	position: relative;
	overflow: hidden;
	height: 240px;
	margin: 0;
}
#scpr li::after {
	content: "";
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	background: #f5f6f8 center / 400px no-repeat;
}

できませんでした。li要素はちゃんとスクロールしてるみたいですけれど、最後のコマのイラストが画面いっぱいに表示されたまま、なにも動きません。擬似要素がぜんぶ重なってしまってるんですね。
positionプロパティでfixedを指定すると、親要素のoverflowプロパティの指定が無視されちゃうんです。

そう思っていたのですけれど、li要素にz-indexプロパティを指定すれば、トリミングすることができるんですね…!
詳しくはこちらの記事にまとめましたX)

要素を矩形に切り抜く

overflow: hiddenではトリミングできなかったけれど、clip-pathプロパティを使えば、要素の特定の部分を切り抜くクリッピングすることができます。
切り抜くカタチは、円や楕円や多角形など、どんな形状でも、シェイプパスの情報があれば、なんだって切り抜けるのですけれど、今回は矩形に切り抜けばよいので、polygon()という形状関数を使って切り抜く形を指定することにします。
li要素でoverflow: hiddenしようとしていた代わりに、li要素の形にclip-path: polygon()で切り抜くことができれば、なんだかうまくいきそうな気がしますXD

切り抜くためのプロパティ

overflow: hiddenの代わりにclip-pathプロパティを使って切り抜きたい

polygon()の括弧の中には、多角形のかどの座標位置を、時計回りに,カンマ区切りで指定します。角を表す座標位置は、X座標とY座標を半角スペースで区切って指定します。
例えば、下図のような形状に切り抜くには、polygon(54px 106px, 152px 38px, 271px 103px, 217px 222px, 83px 220px, 99px 156px)という具合。角の数が多いとけっこう長ったらしくなっちゃいますね。

polygon()関数での、シェイプパス情報の指定方法

今回は四角形なので、左上、右上、右下、左下の順に指定することにします。
例えば、下図みたく、画像の中央ら辺を四角く切り抜きたい時には、polygon(20px 120px, 280px 120px, 280px 220px, 20px 220px)という具合に指定すればよいですね;D

パンちゃんの画像の中央部分を切り抜く

Can I use...によると、Safariではベンダープレフィックスが必要のようなので、-webkit-clip-path: polygon()も一緒に指定しておきます。

.clip {
	-webkit-clip-path: polygon(20px 120px, 280px 120px, 280px 220px, 20px 220px);
	clip-path: polygon(20px 120px, 280px 120px, 280px 220px, 20px 220px);
}

未対応のブラウザのために

ただし、IEEdgeは、clip-pathプロパティに未対応なので、代わりにclipプロパティを使うことにします。
clip-pathプロパティとちょっと似ているけれど、clipプロパティは矩形にしか切り抜くことができません。あと、座標の指定の仕方が違ったり、適用する要素には、positionプロパティでabsolutefixedを指定してある必要があるので注意です。
clipプロパティで四角く切り抜くには、rect()という関数を使います。括弧の中には、要素の上端から、切り抜く上辺/下辺までの距離と、要素の左端から、切り抜く左辺/右辺までの距離を、上辺、右辺、下辺、左辺の順に指定します。ちょっと複雑だけれど、図にすると以下の通り。上のclip-pathプロパティのサンプルと同じように切り抜きたい場合には、rect(120px, 280px, 220px, 20px)という具合に指定すればよいことになります。

clipプロパティでパンちゃんの画像の中央部分を切り抜く

clip-pathプロパティの時と、並べる順番は違うけれど、使う“値”は一緒なので、割とわかりやすいんじゃないでしょうか:)

.clip {
	position: absolute;
	top: 200px;
	left: calc(50% - 200px);
	clip: rect(120px, 280px, 220px, 20px);
}

うまく切り抜くことができました!
けどなんだか、clipプロパティだけでも、ChromeやSafariで問題なく切り抜けてるようですね…(2019年1月現在)
実は、clipプロパティは“ウェブ標準から削除されて非推奨”とのことで、いずれ使えなくなるプロパティなんです(下記ページ参照のこと)。なので、clipプロパティは、あくまでIE/Edge用として指定しておくことになります。

clip | MDN

li要素の形に切り抜くことはできたけれど、すべてのli要素の位置を自力で調べるのは困難だし、スクロールに合わせて切り抜く位置をズラしていくなんて、CSSだけではできないので、ここからはJavaScriptのお仕事になります!

スクロールに合わせて切り抜く位置を動かす

つまり、スクロールするたびに、li要素の座標位置を取得して、背景画像を切り抜くための、clip-pathプロパティの値として適用しなくちゃいけないということ。
今は、li要素の擬似要素に背景画像を表示していますけれど、JavaScriptから擬似要素のスタイルは直接変更できないので、擬似要素はやめて、li要素の中に「空っぽのdiv要素」を入れて、そこに背景画像を表示するようにします。

<ul>
	<li class="f01"><div></div></li>
	<li class="f02"><div></div></li>
	<li class="f03"><div></div></li>
	︙

HTMLに合わせてCSSも修正しておきます。

#scpr li {
	height: 240px;
	margin: 0;
}
#scpr li div {
	position: fixed;
	top: 0;
	︙
}
#scpr .f01 div { background-image: url(../img/jump/01.png); }
#scpr .f02 div { background-image: url(../img/jump/02.png); }
#scpr .f03 div { background-image: url(../img/jump/03.png); }
︙

li要素の座標位置を取得する

まずは、すべてのli要素の各辺の座標位置を取得して、スクロールするたびにli要素の形状の通りに、clip-path/clipプロパティの値を作って、div要素に適用していきます。

JavaScriptで、li要素の位置を取得して、clip-pathプロパティの値として使う

すべてのli要素の姿形を取得しないといけないので、ページがすべて読み込まれてから実行します。
以下のように書くと、ページが読み込まれたのを見計らって、{ }の中に書いたJavaScriptが実行されます。

document.addEventListener('DOMContentLoaded', function() {
	︙
});

ページの読み込みが完了したら、最初に、itemsという変数に、すべてのli要素を代入しておきます(下記4行め)。と同時に、すべてのli要素の情報を、itemDataという変数に保持しておきます(下記12行めから)
li要素の情報を代入してゆく処理は以下のとおり。gatItemData()という関数にまとめます。

var items, itemData = [];
document.addEventListener('DOMContentLoaded', function() {
	items = scprElm.querySelectorAll("#scpr li");
	gatItemData();
});
function gatItemData() {
	let scTop = window.pageYOffset;
	for (let i = 0; i < items.length; i++) {
		let itemRect = items[i].getBoundingClientRect();
		itemData[i] = {
			div: items[i].firstElementChild,
			top: itemRect.top + scTop,
			left: itemRect.left,
			width: itemRect.width,
			height: itemRect.height + scTop
		}
	}
}

gatItemData()では、li要素の数の分だけfor文繰り返し処理を行って、

  • li要素の中のdiv要素
  • 画面の上端からli要素の上辺までの距離
  • 画面の上端からli要素の下辺までの距離
  • 画面左端からli要素の左辺までの距離
  • 画面左端からli要素の右辺までの距離

という5つの情報を順に取得して、itemDataの中に、li要素ごとに配列として保持していきます。

そのほかJavaScriptのメソッドなどについては、詳しくは以下のサイトを参照のこと。

スクロール中は“取得して適用”を繰り返す

スクロール中に行う処理はclipScrollという関数にまとめます。
保持しておいたitemDataの値と、現在のスクロール位置を元に、clip-path/clipプロパティ用の値を作って、div要素に適用します。

function clipScroll() {
	let scTop = window.pageYOffset;
	let left = itemData[0].left + "px";
	let right = itemData[0].right + "px";
	for (let i = 0; i < items.length; i++) {
		let top = itemData[i].top - scTop + "px";
		let btm = itemData[i].btm - scTop + "px";
		
		itemData[i].div.style["clip"] = "rect(" + top + "," + right + "," + btm + "," + left + ")";
		let clipPath = left + " " + top + "," + right + " " + top + "," + right + " " + btm + "," + left + " " + btm;
		itemData[i].div.style["-webkit-clip-path"] = "polygon(" + clipPath + ")";
		itemData[i].div.style["clip-path"] = "polygon(" + clipPath + ")";
	}
}

この関数を、ブラウザ画面windowがスクロールしている時に実行されるよう、以下のように、「スクロールイベントが発生した時の処理」として、clipScroll()を登録します。
また、ブラウザ画面のサイズを変更して、li要素の位置がズレた場合には、改めてli要素の座標位置を取得し直さないといけないので、「リサイズイベントが発生した時の処理」として、gatItemData()も登録しておきます。

document.addEventListener('DOMContentLoaded', function() {
	items = scprElm.querySelectorAll("#scpr li");
	gatItemData();
	window.addEventListener("scroll", clipScroll);
	window.addEventListener("resize", gatItemData);
});

その他、もろもろ調整したらできあがり(詳しくはソースコードを参照のことX‹

未対応のブラウザだけJavaScriptにする

clipプロパティclip-pathプロパティを両方とも指定しておくのは無駄なので、clip-pathプロパティに対応しているブラウザではclip-pathプロパティだけ、対応していないものはclipプロパティだけ適用するようにしたいです。
ブラウザが該当のプロパティに対応しているかどうか判別するには、以下のように書きます。

let div = document.createElement("div");
div.style.cssText = "clip-path: polygon(1px 1px, 9px 1px, 9px 9px, 1px 9px);";
div.style.cssText += "-webkit-clip-path: polygon(1px 1px, 9px 1px, 9px 9px, 1px 9px)";
let hasClipPath = div.style.length > 0;

一旦、JavaScriptの中だけでdiv要素を作って、そのdiv要素のstyle属性clip-path: polygon(1px 1px, 9px 1px, 9px 9px, 1px 9px)というスタイルを書き込んでおいてから、div要素に適用されてるstyleの数を数えて、hasClipPathという変数に代入してます。
ちゃんと数に入ってたら「対応している」ということでhasClipPathtrueで、0だったらfalseになるって寸法です。

この変数を、以下のようにif文条件として使います。これで、clip-pathプロパティに対応していなければ最初の{ }の中を実行して、対応していればelseの後の{ }の中を実行するようになるはずです:)

if (!hasClipPath) {
	itemData[i].div.style.cssText = "clip: rect(" + top + "," + right + "," + btm + "," + left + ")";
} else {
	let clipPath = left + " " + top + "," + right + " " + top + "," + right + " " + btm + "," + left + " " + btm ;
	itemData[i].div.style.cssText = "-webkit-clip-path: polygon(" + clipPath + ")";
	itemData[i].div.style.cssText += "clip-path: polygon(" + clipPath + ")";
}

IE/Edgeではclipプロパティ、それ以外ではclip-pathプロパティだけが適用されるようになりましたね;)


同じく、background-attachment: fixedに対応しているブラウザではCSSでパラパラして、未対応のブラウザだけJavaScriptを使ったパラパラにするようにしたいです。
clip-pathの時と同じ要領で、hasAttachmentという変数に、対応していればtrue、未対応ならfalseが代入されるようにします。

let div2 = document.createElement("div");
div2.style.cssText = "background-attachment: fixed";
var hasAttachment = div2.style.length > 0;

最初のDOMContentLoadedイベントのところを、if文を使って、以下のように書き直します。これで、background-attachment: fixedに未対応のブラウザのみ、{ }の中が実行されるようになるはずです!

document.addEventListener('DOMContentLoaded', function() {
	if (!hasAttachment) {
		items = scprElm.querySelectorAll("#scpr li");
		gatItemData();
		window.addEventListener("resize", onResize);
		window.addEventListener("scroll", onScroll);
	}
});

と思ったけれど、iPhoneもAndroidのChromeも、JavaScriptが動かなくなっちゃいました…。
どうやら、background-attachmentには対応してるからか、div2.style.lengthで数に入れられちゃったみたいです。
プロパティが“適用されているか/されていないか”で判別するのは諦めて、ここでは、ブラウザのユーザーエージェントで、明示的にブラウザを判別することにします!X‹

background-attachmentプロパティが効いてなかったのは、iOS Safari/Chromeと、Android Chrome(2019年1月現在)。つまり、iPhone全般と、Android(ただしChrome)という事として、以下のような条件で、振り分けることにします。

let ua = window.navigator.userAgent.toLowerCase(),
	isIos = ua.indexOf("iphone") != -1 || ua.indexOf("ipad") != -1,
	isAndroid = ua.indexOf('android') != -1 && ua.indexOf('chrome') != -1;

このisIosisAndroidを条件に、以下のように書けば、iOSの時、もしくはAndroidでChromeの時だけ{ }の中を実行するはずです!

document.addEventListener('DOMContentLoaded', function() {
	if (isIos || isAndroid) {
		items = scprElm.querySelectorAll("#scpr li");
		gatItemData();
		window.addEventListener("resize", onResize);
		window.addEventListener("scroll", onScroll);
	}
});

うん、ちゃんとJavaScriptが動くようになりました;D

ユーザーエージェントについては以下の記事を参照のこと。
User Agentを用いたブラウザーの判定 | MDN

※上記のページにも喚起されているように、ユーザーエージェントを用いたブラウザの判別は、なるべく回避しなきゃいけないこと…。よりよい方法が見つかったら、追記するかもですX(

ここで気付いたのですけれど、IE/Edgeはbackground-attachmentプロパティに対応しているのだから、clipプロパティ使わなくても大丈夫でしたね…Xp
てなもんで、以上、「スクロールパラパラ」について、でした!最後まで読んでいただきありがとうございました:D