CSS Deep-Dive - フレーム完璧なカスタム スクロールバーのための matrix3d()

カスタム スクロールバーは非常にまれです。これは、スクロールバーが、スタイルを設定できないウェブ上の残りの要素の一つであるためです(日付選択ツールなど)。JavaScript を使用して独自のモデルを構築することもできますが、費用が高く、忠実度が低く、遅延が生じる可能性があります。この記事では、通常とは異なる CSS 行列を利用して、スクロール中に JavaScript を必要とせず、設定コードのみでカスタム スクロールを作成する方法を説明します。

要約

小さなことは気にしない?Nyan cat のデモを見てライブラリを入手したいだけですか?デモのコードは GitHub リポジトリにあります。

LAM;WRA(長くて数学的な内容だが、読む)

以前、パララックス スクロールを作成しました(こちらの記事を読みましたか?ぜひご覧ください。CSS 3D 変換を使用して要素を押し戻すことで、要素が実際のスクロール速度よりも遅く移動しました。

内容のまとめ

まず、パララックス スクロールの仕組みを簡単に説明します。

アニメーションに示すように、3D 空間の Z 軸に沿って要素を「後方」に押し出すことで、視差効果を実現しています。ドキュメントのスクロールは、実質的に Y 軸に沿った移動です。たとえば 100 ピクセルを下にスクロールすると、すべての要素が 100 ピクセルを上に移動します。これは「後ろにある」要素にもすべて適用されます。ただし、カメラから遠いため、画面上で観測される動きは 100 ピクセル未満になり、望ましい視差効果が得られます。

もちろん、要素を空間内で後方に移動すると、要素は小さく見えます。この場合は、要素を拡大して修正します。パララックス スクロールを作成する際に正確な計算を行ったため、詳細についてはここでは説明しません。

ステップ 0: 何をしたいですか?

スクロールバー。これを構築します。しかし、彼らが何をしているのか、真剣に考えたことはありますか?私もそうは思いませんでした。スクロールバーは、現在表示されている利用可能なコンテンツのと、読者として進んでいる進捗状況を示すインジケーターです。下方向にスクロールすると、スクロールバーも下方向に移動し、最後までスクロールしていることを示します。すべてのコンテンツがビューポートに収まる場合、通常はスクロールバーは非表示になります。コンテンツの高さがビューポートの高さの 2 倍の場合、スクロールバーはビューポートの高さの 1/2 を占有します。ビューポートの高さの 3 倍のコンテンツは、スクロールバーをビューポートの 1/3 にスケーリングします。パターンは明らかです。スクロールする代わりに、スクロールバーをクリックしてドラッグすると、サイトをすばやく移動できます。このような目立たない要素で、これほど多くの動作を行うのは驚きです。1 つずつ戦いましょう。

ステップ 1: リバースにする

パララックス スクロールの記事で説明したように、CSS 3D 変換を使用して、要素をスクロール速度よりも遅く動かすことができます。方向を逆にすることもできますか?実は、フレーム パーフェクトなカスタム スクロールバーを作成するための方法があります。この仕組みを理解するには、まず CSS 3D の基本をいくつか説明する必要があります。

数学的な意味でのあらゆる種類の遠近投影を取得するには、ほとんどの場合、均質座標を使用する必要があります。詳細については説明しません。4 番目の座標 w が追加された 3D 座標と考えてください。遠近感の歪みを設定する場合を除き、この座標は 1 にする必要があります。1 以外の値は使用しないため、w の詳細については心配する必要はありません。したがって、すべてのポイントは今後 4 次元ベクトル [x, y, z, w=1] になり、マトリックスも 4x4 にする必要があります。

CSS が内部でホモジニアス座標を使用していることを確認できる例として、matrix3d() 関数を使用して transform プロパティで独自の 4x4 行列を定義する場合が挙げられます。matrix3d は 16 個の引数を取ります(行列が 4x4 であるため)。列を 1 つずつ指定します。この関数を使用すると、回転や移動などを手動で指定できますが、w 座標を操作することもできます。

matrix3d() を使用するには、3D コンテキストが必要です。3D コンテキストがないと、遠近感の歪みが発生せず、均質座標が必要ないためです。3D コンテキストを作成するには、perspective を含むコンテナと、新しく作成された 3D 空間で変換できる要素が必要です。:

CSS の perspective 属性を使用して div を歪ませる CSS コード。

パースペクティブ コンテナ内の要素は、CSS エンジンによって次のように処理されます。

  • 要素の各コーナー(頂点)を、パースペクティブ コンテナを基準とした均質座標 [x,y,z,w] に変換します。
  • 要素のすべての変換を右から左にマトリックスとして適用します。
  • パースペクティブ要素がスクロール可能である場合は、スクロール マトリックスを適用します。
  • 遠近感マトリクスを適用します。

スクロール行列は Y 軸に沿った平行移動です。400 ピクセルにスクロールダウンすると、すべての要素を 400 ピクセルに移動する必要があります。パースペクティブ マトリックスは、3D 空間の奥にあるポイントを消失点に近づけるマトリックスです。これにより、遠くにあるものは小さく見え、移動するときに「遅く動く」という両方の効果が得られます。そのため、要素が押し戻されると、400 ピクセルの移動で要素は画面上で 300 ピクセルしか移動しません。

詳細をすべて知りたい場合は、CSS の変換レンダリング モデルの仕様をお読みください。この記事では、上記のアルゴリズムを簡素化しています。

ボックスは、perspective 属性の値が p のパースペクティブ コンテナ内にあります。コンテナはスクロール可能で、n ピクセル下にスクロールされているとします。

遠近行列 × スクロール行列 × 要素変換行列は、4 × 4 の単位行列(4 行目 3 列目に -1 ÷ p がある)× 4 × 4 の単位行列(2 行目 4 列目に -n がある)× 要素変換行列に等しい。

最初の行列はパースペクティブ行列、2 番目の行列はスクロール行列です。要約すると、スクロール マトリックスの役割は、下にスクロールするときに要素を上に移動することです。そのため、負の符号が付いています。

ただし、スクロールバーの場合はにする必要があります。つまり、下にスクロールすると要素が下に移動するようにします。ここで、ボックスの角の w 座標を反転するというトリックを使用できます。w 座標が -1 の場合、すべての変換が反対方向に適用されます。では、どうすればよいでしょうか。CSS エンジンは、ボックスの角を均質座標に変換し、w を 1 に設定します。matrix3d() の登場です。

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    );
}

この行列は、w を負にする以外に何もしません。したがって、CSS エンジンが各コーナーを [x,y,z,1] 形式のベクトルに変換すると、行列はそれを [x,y,z,-1] に変換します。

4 行 4 列の単位行列(4 行目 3 列目が -1 ÷ p)× 4 行 4 列の単位行列(2 行目 4 列目が -n)× 4 行 4 列の単位行列(4 行目 4 列目が -1)× 4 次元ベクトル(x, y, z, 1)は、4 行 4 列の単位行列(4 行目 3 列目が -1 ÷ p、2 行目 4 列目が -n、4 行目 4 列目が -1)× 4 次元ベクトル(x, y + n, z, -z ÷ p - 1)に等しい。

要素変換行列の効果を示すために、中間ステップをリストしました。行列演算に慣れていない場合でも問題ありません。最後の行では、スクロール オフセット n を y 座標から減算するのではなく、加算していることに気付くはずです。下にスクロールすると、要素は下に移動します。

ただし、この行列をにそのまま配置しても、要素は表示されません。これは、CSS 仕様で、w < 0 の頂点は要素のレンダリングをブロックすることが義務付けられているためです。現在、z 座標は 0、p は 1 であるため、w は -1 になります。

幸い、z の値は選択できます。w=1 になるようにするには、z = -2 に設定する必要があります。

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    )
    translateZ(-2px);
}

なんと、ボックスが復活しました

ステップ 2: 動かす

箱が配置され、変換なしの場合と同じように表示されます。現在、パースペクティブ コンテナはスクロールできないため、表示されませんが、スクロールすると要素が反対方向に移動することはわかっています。コンテナをスクロールできるようにしましょう。スペースを占有するスペーサー要素を追加するだけです。

<div class="container">
    <div class="box"></div>
    <span class="spacer"></span>
</div>

<style>
/* … all the styles from the previous example … */
.container {
    overflow: scroll;
}
.spacer {
    display: block;
    height: 500px;
}
</style>

ボックスをスクロールします。赤いボックスが下に移動します。

ステップ 3: サイズを指定する

ページを下にスクロールすると下に移動する要素があります。これで、難しい部分はすべて完了しました。次に、スクロールバーのように見えるようにスタイルを設定し、インタラクティブ性を高めます。

通常、スクロールバーは「つまみ」と「トラック」で構成されますが、トラックは常に表示されるとは限りません。サムネイルの高さは、表示されるコンテンツの量に正比例します。

<script>
    const scroller = document.querySelector('.container');
    const thumb = document.querySelector('.box');
    const scrollerHeight = scroller.getBoundingClientRect().height;
    thumb.style.height = /* ??? */;
</script>

scrollerHeight はスクロール可能な要素の高さで、scroller.scrollHeight はスクロール可能なコンテンツの合計高さです。scrollerHeight/scroller.scrollHeight は、表示されるコンテンツの割合です。サムネイルが占有する垂直方向のスペースの比率は、表示されるコンテンツの比率と同じにする必要があります。

スクロールバーの高さに対するサムドット スタイルのドット高さが、スクロールバーのドット スクロールの高さに対するスクロールバーの高さに等しい場合、かつその場合に限って、スクロールバーの高さに対するサムドット スタイルのドット高さが、スクロールバーの高さ × スクロールバーのドット スクロールの高さに対するスクロールバーの高さに等しくなります。
<script>
    // …
    thumb.style.height =
    scrollerHeight * scrollerHeight / scroller.scrollHeight + 'px';
    // Accommodate for native scrollbars
    thumb.style.right =
    (scroller.clientWidth - scroller.getBoundingClientRect().width) + 'px';
</script>

サムネイルのサイズは問題ないものの、動きが速すぎます。ここで、パララックス スクロールからテクニックを取得できます。要素をさらに後ろに移動すると、スクロール中の移動速度が遅くなります。サイズを拡大することで、この問題を修正できます。では、どれくらい遅らせればよいのでしょうか。では、計算してみましょう。これが最後です。

重要な情報は、一番下までスクロールしたときに、サムネイルの下端がスクロール可能な要素の下端と揃っていることです。つまり、scroller.scrollHeight - scroller.height ピクセルをスクロールした場合、親指が scroller.height - thumb.height ピクセル分移動します。スクロールの 1 ピクセルごとに、親指が 1 ピクセル未満動くようにします。

係数は、スクロール ドットの高さ - サムネイル ドットの高さ ÷ スクロール ドットのスクロール高さ - スクロール ドットの高さです。

これがスケーリング ファクタです。次に、スケーリング ファクタを z 軸方向の移動に変換する必要があります。これは、パララックス スクロールの記事ですでに行っています。仕様の該当するセクションによると、スケーリング ファクタは p/(p − z) に等しくなります。この方程式を解いて z を求めることで、親指を z 軸に沿って移動させる必要がある距離を計算できます。ただし、w 座標の不正行為により、z に沿って追加の -2px を変換する必要があります。また、要素の変換は右から左に適用されます。つまり、特殊な行列の前のすべての変換は反転されませんが、特殊な行列の後のすべての変換は反転されます。これをコード化しましょう。

<script>
    // ... code from above...
    const factor =
    (scrollerHeight - thumbHeight)/(scroller.scrollHeight - scrollerHeight);
    thumb.style.transform = `
    matrix3d(
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, -1
    )
    scale(${1/factor})
    translateZ(${1 - 1/factor}px)
    translateZ(-2px)
    `;
</script>

スクロールバーがあります。自由にスタイル設定できる DOM 要素にすぎません。ユーザー補助の観点から重要なことの一つは、多くのユーザーがスクロールバーをそのように操作することに慣れているため、親指がクリックとドラッグに応答するようにすることです。このブログ投稿を長くしないため、この部分の詳細については説明しません。詳細については、ライブラリ コードをご覧ください。

iOS の場合はどうなりますか?

ああ、おなじみの iOS Safari です。パララックス スクロールと同様に、ここで問題が発生します。要素をスクロールするため、-webkit-overflow-scrolling: touch を指定する必要がありますが、これにより 3D フラット化が発生し、スクロール エフェクト全体が機能しなくなります。パララックス スクロールでは、iOS Safari を検出して position: sticky を回避策として使用することでこの問題を解決しました。ここでもまったく同じことを行います。パララックスに関する記事で、パララックスについて確認してください。

ブラウザのスクロールバーはどうなりますか?

システムによっては、永続的なネイティブ スクロールバーを扱う必要があります。これまで、スクロールバーを非表示にすることはできませんでした(標準以外の疑似セレクタを使用した場合を除く)。そのため、それを隠すには、(数学を使わない)ハッキングに頼る必要があります。スクロール要素を overflow-x: hidden でコンテナにラップし、スクロール要素をコンテナよりも広くします。ブラウザのネイティブ スクロールバーは表示されなくなりました。

フィン

ここまでの説明をすべてまとめると、Nyan cat デモのような、フレーム パーフェクトなカスタム スクロールバーを作成できます。

ニャンキャットが表示されない場合は、このデモの作成中にGoogle が検出して報告したバグが発生しています(再生ボタンをクリックするとニャンキャットが表示されます)。Chrome は、画面外のペイントやアニメーション化などの不要な作業を回避するのに非常に優れています。残念ながら、このマトリックスのトリックで、Chrome はニャンコの GIF が実際には画面外にあると判断します。近日中に修正される予定です。

これで準備は完了です。そのため、作業が煩雑になる場合があります。最後までお読みいただきありがとうございます。これを機能させるには、かなりの技術が必要です。カスタマイズされたスクロールバーがエクスペリエンスの重要な部分である場合を除き、この作業に費やす時間はほとんどありません。可能であることは知っておいて損はないですね。カスタム スクロールバーを作成するのがこれほど難しいということは、CSS 側で作業が必要なことを意味します。ご安心ください。 今後、HoudiniAnimationWorklet により、このようなフレーム パーフェクトのスクロールリンク エフェクトが大幅に容易になります。