乗りこなせ! モダンフロントエンド

モダンなスクロール関連機能を使いこなそう
[CSS Modern Features no.7]

スクロールに絡む機能の実装

こんにちは! サイボウズ フロントエンドエキスパートチームの麦島です。今回のテーマは「スクロール」に関するモダンな機能群です。

ブラウザ上でスクロールに絡む機能を実装しようとすると、考慮すべき点や制約は多くなります。

たとえば、スクロールに応じて何か処理をする場合、JavaScriptによるスクロールの監視が必要です。このスクロールの監視処理は、イベントがトリガーされる回数が非常に多くなりやすく、パフォーマンスへの考慮が必要となります。

他にも、スクロールに応じた計算処理も複雑になりがちです。スクロールコンテナ自体のサイズ・内包コンテンツのサイズ・現在のスクロール位置・関連する要素の座標やサイズ、といった多くの情報に基づいた計算が必要となります。似たような計算処理の実装を何度もした、という経験がある方もいるのではないでしょうか。

スクロール関連機能の拡充

実装時に考慮事項が多いスクロールですが、昨今では、スクロールに関連する仕様策定およびブラウザでの機能実装が進んでいます。

たとえば、Web Platform Statusでは、機能ごとのブラウザ互換性を表す指標であるBaselineの状況を確認できます。ここで"scroll"で検索すると、多くの新しい仕様が存在し、一部はすでにブラウザ上でも実装済みであることがわかります。

Web Platform Statusを"scroll"で検索した結果

これらの仕様には、従来課題とされていた点が大きく改善されるものも含まれます。それぞれ新しく何ができるようになり、どういった課題を解決するのかを確認していきましょう。

なお、今回紹介する機能は、本記事掲載時点では仕様的にはWorking Draftのものが含まれます。今後内容が変更される可能性がある点に注意してください。

スクロールバーのスタイリング

従来、スクロールバー自体のスタイルのカスタマイズには制限がありました。一部ブラウザでは::-webkit-scrollbar擬似要素などでスタイル指定ができましたが、ブラウザが限定されます。

新しい仕様であるscrollbar-widthscrollbar-colorscrollbar-gutterを用いることで、スクロールバー自体のスタイルおよび、スクロールバーの表示有無に伴うレイアウトの調整が可能となりました。

scrollbar-width, scrollbar-color

scrollbar-widthは、スクロールバーの幅を指定するためのプロパティです。

デフォルトであるautoに加えて、次の値が指定できます。

  • none: スクロールバーを非表示にする(スクロールは可能)
  • thin: デフォルトより細いスクロールバーを表示する

scrollbar-colorは、スクロールバーのカラーを変更するためのプロパティです。カラーは2色指定可能で、1つ目はスクロールバーのノブ(つまみ⁠⁠、2つ目はトラック(背景)の色を指定します。

scrollbar-widthおよびscrollbar-colorの両方を指定した例を見てみましょう。

<div class="container">
  <div class="content"></div>
</div>
.container {
  overflow-y: scroll;
  height: 150px;
  width: 150px;
  border: 1px solid #000;

  /* スクロールバーの幅を細く */
  scrollbar-width: thin;
  /* つまみを黒、背景を赤に */
  scrollbar-color: black red;
}

.content {
  height: 1000px;
}

同様のスタイルでスクロールバーのスタイルだけを含めないコンテナと並べた場合、次のような結果となります。

スクロールバーのスタイルを指定した例

スクロールバーの幅がやや細くなり、背景が赤く、つまみが黒色になりました。

なお、見た目の変更はアクセシビリティにも影響を与える点には注意が必要です。例えばカラーを変更する場合には、適切なコントラストを確保する必要があり、これは仕様上でも次のように言及されています[1]

scrollbar-gutter

scrollbar-gutterは、スクロールバーの幅分の余白を確保するためのプロパティです。

従来のよくある問題として、スクロールバーが自動的に表示される際に、スクロールバーの幅だけレイアウトが変化してしまう、というものが挙げられます。

次の例を見てみましょう。

<div class="container-wrapper">
  <div class="container">
    <!-- 折り返すコンテンツ -->
    <div class="content">ABCDEFGHIJKLMNOPQRSTUVWXYZ</div>
  </div>
  <!-- スクロールが発生するコンテナ -->
  <div class="container">
    <!-- 複数の折り返すコンテンツ -->
    <div class="content">ABCDEFGHIJKLMNOPQRSTUVWXYZ</div>
    <div class="content">ABCDEFGHIJKLMNOPQRSTUVWXYZ</div>
  </div>
</div>
.container-wrapper {
  display: flex;
  gap: 16px;
}

.container {
  overflow-y: auto;
  height: 150px;
  width: 150px;
  border: 1px solid black;
}

.content {
  word-break: break-all;
  border: 1px solid red;
  padding: 4px;
  margin: 4px;
}

それぞれ、2つあるコンテナは横幅に入り切らずに折り返すコンテンツを保持しています。また、後者のコンテナは、より多くのコンテンツを含め、スクロールが発生するようにしています。

この場合、次のような見た目となります。

スクロールバーの有無によるレイアウトの変化

ポイントは、内部のテキストの折り返し位置が変わっているという点です。

これは、スクロールバーが表示されると、その幅に応じてコンテンツの表示可能領域も変化するのが原因です。

動的にデータを取得してコンテンツを追加していくようなケースでは特に目立ちやすく、スクロールバーが表示されたタイミングで表示がガタついたような印象を受けます。

scrollbar-gutterを指定し、あらかじめスクロールバーの幅分の余白を確保しておくことで、このガタつきを抑制できます。

先程の例に、scrollbar-gutterだけを新たに追加してみます。

.container {
  overflow-y: auto;
  height: 150px;
  width: 150px;
  border: 1px solid black;
  /* スクロールバーの余白を確保 */
  scrollbar-gutter: stable;
}

すると、次のようにスクロールバーの表示有無に関わらず、スクロールバーの幅分だけ余白が確保されており、コンテンツの折り返し位置が統一されていることがわかります。

scrollbar-gutterを指定しスクロールバーの余白を確保した例

なお、上記のstableを指定した例では、右側にのみ余白が確保されています。結果的にコンテンツが左に寄っている印象を受けますが、値としてstable both-edgesを指定することで、左側にも同様の余白を確保し、コンテンツが中央に来るように表示することも可能です。

CSSによるスクロールのコントロール

スクロールは、スクロールバーのドラッグ・マウスホイール・モバイル端末のスワイプといった操作で行われます。

しかし、提供するアプリケーションによっては、次のように任意のタイミングでのスクロールが必要となるケースがあります。

  • ボタンをクリックした際にスクロールしてほしい
  • 複数並んでいるコンテンツのうち、次(前)の要素までスクロールさせたい
  • 指定のコンテンツの位置までスクロールさせたい

従来これらはJavaScriptによる制御が必須でしたが、新しいCSS仕様を用いることで、CSSのみで基本的なスクロールのコントロールを実現できます。

::scroll-button()擬似要素

::scroll-button()擬似要素は、スクロール可能なコンテナ単位で、スクロールを制御するためのボタンを表すための擬似要素です。

通常は非表示ですが、CSS上でcontentnone以外の有効な値を指定すると表示されます。

まずは、ミニマムな例で動作を見てみましょう。

<div class="container">
  <div class="content">
    <div>先頭コンテンツ</div>
    <div>中央コンテンツ</div>
    <div>末尾コンテンツ</div>
  </div>
</div>
.container {
  overflow-y: scroll;
  height: 150px;
  width: 150px;
  border: 1px solid black;

  /** 上方向へのスクロールボタンを表示 */
  &::scroll-button(up) {
    content: "▲";
  }
  /** 下方向へのスクロールボタンを表示 */
  &::scroll-button(down) {
    content: "▼";
  }
}

.content {
  height: 500px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

ポイントは、&::scroll-button(up)および&::scroll-button(down)の箇所です。content以外のプロパティは特に何も指定していません。

動作を確認してみると、次のようにcontentに指定した文字列でボタンが表示されており、upおよびdownに対応したボタンをクリックすると、該当する方向にスクロールします。

::scroll-button()の基本的な動作例(アニメーションGIF)

JavaScriptでの実装は一切無い状態で、スクロールボタンが実現できました。

up down以外にもleft rightや、論理プロパティに相当するblock-start block-end inline-start inline-endなども指定可能です。また、*を指定することで、共通したボタンのスタイリングも行えます。

/** スクロールボタンのスタイリング */
&::scroll-button(*) {
  background-color: #000;
  color: #fff;
  border-radius: 100%;
  display: inline-flex;
  justify-content: center;
  align-items: center;
  width: 24px;
  height: 24px;
  margin: 4px;
}
::scroll-button()の共通スタイリングを指定した例

::scroll-markerおよび::scroll-marker-group擬似要素

::scroll-button()は、単純に方向を指定したスクロール用のコントロールを提供します。一方で、⁠特定のコンテンツの位置までスクロールさせたい」というケースも考えられます。

代表的な例がカルーセルです。

カルーセルは、写真やバナーなどを横スクロールで表示し、コンテンツ付近にあるマーカーをクリックすることでスクロールして対応するコンテンツが中央に来る、といったもので、ライブラリの利用や独自実装などで広く利用されています。

::scroll-marker::scroll-marker-group擬似要素を用いることで、クリックすることでスクロールするマーカーの表示および、マーカーを束ねた位置などのスタイリングが可能となります。

多少わかりづらいですが、重要なのは次の点です。

  • マーカーに対応する要素配下の::scroll-marker擬似要素でマーカーをスタイリングする
  • スクロール可能なコンテナにscroll-marker-groupでマーカーの表示位置を決める
  • スクロール可能なコンテナ配下の::scroll-marker-group擬似要素でマーカーを束ねた配置・整列などのスタイリングを行う

実際に見てみましょう。

まず、シンプルに横スクロール可能なコンテナを作ってみます。

<div class="container">
  <div class="content">1</div>
  <div class="content">2</div>
  <div class="content">3</div>
  <div class="content">4</div>
  <div class="content">5</div>
  <div class="content">6</div>
  <div class="content">7</div>
</div>
.container {
  overflow-x: scroll;
  width: 200px;
  height: 100px;
  border: 1px solid black;
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 16px;
}

.content {
  width: 50px;
  height: 50px;
  min-width: 50px;
  min-height: 50px;
  border: 2px solid red;
  display: inline-flex;
  justify-content: center;
  align-items: center;
}
横スクロール可能なコンテナの例

横スクロール可能なコンテナができました。

では、ここに::scroll-markerおよび::scroll-marker-group擬似要素でマーカーを表示してみましょう。

CSSを次のように変更します。

.container {
  /* 〜省略〜 */

  /* マーカーグループの表示位置を指定 */
  scroll-marker-group: after;

  /* マーカーグループのスタイリング */
  &::scroll-marker-group {
    /* マーカーを横並びに配置 */
    display: flex;
    gap: 8px;
    margin-top: 8px;
  }
}

.content {
  /* 〜省略〜 */

  /* マーカーのスタイリング */
  &::scroll-marker {
    display: block;
    content: "";
    width: 16px;
    height: 16px;
    border: 1px solid red;
    border-radius: 100%;
  }
  /* 現在のマーカーのスタイリング */
  &::scroll-marker:target-current {
    border: 1px solid red;
    background-color: red;
  }
}
::scroll-markerの動作例(アニメーションGIF)

JavaScriptに頼ることなく次の挙動が実現できました。

  • コンテンツに対応するマーカーを表示
  • マーカーをクリックすると、対応する要素の位置までスクロール
  • スクロール位置に相当するマーカーのスタイルを切り替える

一方で、従来であれば、JavaScriptで次のような実装が必要でした。

  • 各マーカー相当のボタンに対してクリックイベントを設定する
  • ボタンがクリックされたら、対応する要素を取得し、該当する位置までスクロールさせる
  • スクロール自体も監視し、スクロール量に応じてマーカーのclassなどを着脱しスタイルを切り替える

::scroll-marker::scroll-marker-groupによって、いかに実装が軽減できるかがわかるかと思います。

Scroll Snap Events

CSSのスクロールには、Scroll Snapと呼ばれる機能が存在します。Scroll Snapは、ユーザーによるスクロールが停止したタイミングで、指定した要素の境界や特定の位置で自動的にスクロールを止める(スナップする)ための機能です。

Scroll Snapはscroll-snap-typescroll-snap-alignプロパティの指定で有効化できますが、これ自体は新しいプロパティではありません。

次のイメージは、Scroll Snapの動作例です。各要素の中央を基準としており、スクロールバーのドラッグを解除した瞬間、近い要素が中央に来るように自動的にスクロール位置が補正されています。

Scroll Snapの動作例(アニメーションGIF)

従来では、Scroll Snapでスナップされた要素を取得するためには、Intersection Observer APIを用いて監視するといった工夫が必要でした。

Scroll Snapに関するECMAScriptの新しい仕様であるSnap Eventsでは、スナップされる要素の切り替わりに応じて"scrollsnapchange"イベントおよび"scrollsnapchanging"イベントがトリガーされ、スナップされている要素を簡単に特定できるようになります。

「スナップされた要素に応じた説明文を表示したい」というケースを例に考えてみましょう。

まず、簡単なスクロールスナップを実現するHTMLとCSSを用意します。

<section class="container">
  <article class="content" data-index="0">
    <h4>CSS Modern Features no.1</h4>
  </article>
  <article class="content" data-index="1">
    <h4>CSS Modern Features no.2</h4>
  </article>
  <article class="content" data-index="2">
    <h4>CSS Modern Features no.3</h4>
  </article>
  <article class="content" data-index="3">
    <h4>CSS Modern Features no.4</h4>
  </article>
  <article class="content" data-index="4">
    <h4>CSS Modern Features no.5</h4>
  </article>
  <article class="content" data-index="5">
    <h4>CSS Modern Features no.6</h4>
  </article>
</section>

<div class="subtitle"></div>
/* 見た目の調整用のスタイルは省略 */

.container {
  overflow-y: scroll;
  scroll-snap-type: y mandatory;
}

.content {
  scroll-snap-align: center;
}

これまでの本連載タイトルの一部が列挙されています。

しかし、.subtitleを持つ<div>要素については空となっており、ここにスクロールスナップされた項目に相当するサブタイトルを表示したいと思います。

これは、次のようなJavaScriptを用意することで実現できます。

const SUB_TITLES = [
  "新しい擬似クラス:has()⁠⁠、:is()⁠⁠、:where()を使いこなそう",
  "Container Queries/祖先要素に応じたCSSの切り替え",
  "Cascade Layers/レイヤーによる優先順位の制御",
  "CSS Nesting Module/CSSの入れ子指定",
  "Scoped Styles/スコープ付きスタイルルール",
  "Cascade Sorting Order/CSSの適用順序を学び直す",
];

document.addEventListener("DOMContentLoaded", () => {
  const container = document.querySelector(".container");
  const subtitle = document.querySelector(".subtitle");

  // 初期状態のタイトルを表示
  subtitle.innerHTML = SUB_TITLES[0];

  // スナップ要素が変更された際のイベント
  container.addEventListener("scrollsnapchange", (event) => {
    // スナップ要素の data-index 属性をもとにサブタイトルを取得
    const index = Number(event.snapTargetBlock.dataset.index);
    subtitle.innerHTML = SUB_TITLES[index];
  });
});

"scrollsnapchange"イベントによりスクロールスナップの変更を監視し、SnapEventに含まれるsnapTargetBlockをもとに、スナップされた要素を取得しています。

実際の動作は次のようになります。

Scroll Snap Eventsの動作例(アニメーションGIF)

スクロールすると要素の中央にスナップされ、該当する要素に対応するサブタイトルが表示されています。

このように、Scroll Snap Eventsを用いることで、スナップ要素の特定が非常に簡単に行えます。

Container Scroll-State Queries

本連載内のCSS Modern Features no.2 - Container Queries/祖先要素に応じたCSSの切り替えにおいて、祖先要素に応じてスタイルを切り替えるContainer Queriesを紹介しました。

該当の記事の中では、Container Queriesの中でも、主に次の2つの機能を取り上げました。

  • Container Size Queries: コンテナのサイズを判定に用いてスタイルを切り替える
  • Container Style Queries: コンテナが特定のスタイルを持つかを判定に用いてスタイルを切り替える

そして、Container Queriesにはもう一種類の仕様が存在し、それが今回紹介するContainer Scroll-State Queriesです。Container Scroll-State Queriesでは、コンテナのスクロール状態を判定に用いてスタイルの切り替えが可能です。

簡単な例を見てみましょう。先述の::scroll-button()擬似要素による上方向へのスクロールボタンを、スクロール可能な場合のみ表示するようにしてみます。

<div class="container">
  <div class="content">1</div>
  <div class="content">2</div>
  <div class="content">3</div>
</div>
/* 見た目の調整用のスタイルは省略 */

.container {
  overflow-y: scroll;

  /* コンテナとして定義 */
  container-type: scroll-state;

  /* スクロールボタンを非表示状態で有効化 */
  &::scroll-button(up) {
    content: "▲";
    opacity: 0;
    transition: opacity 0.2s;
  }

  /* 上方向へスクロール可能な場合のみ有効となるスタイル */
  @container scroll-state(scrollable: top) {
    /* スクロールボタンを表示 */
    &::scroll-button(up) {
      opacity: 1;
    }
  }
}

@container scroll-state(scrollable: top)によって、コンテナが上方向へスクロール可能な場合のみのスタイルを宣言している点がポイントです。

実際の動作は次のようになります。

Container Scroll-State Queriesの動作例(アニメーションGIF)

下方向へ少しスクロールし、上方向へスクロール可能な状態になると、::scroll-button(up)によるスクロールボタンが表示されています。

なお、例ではscroll-stateに対してscrollableを指定していますが、実際には次の3種類を指定可能です。

  • scrollable
  • snapped
  • stuck

それぞれどういった違いがあるのか確認してみましょう。

scrollable

先述の例でも用いたscrollableは、単純に「コンテナが指定した方向へスクロール可能か」をもとに判定し、スタイルを適用します。

@container scroll-state(scrollable: top) {
  .target {
    /* 上方向へスクロール可能な場合のみのスタイル */
    display: block;
  }
}

スクロール方向を示す値には、次のものが指定可能です。

  • none : スクロール不可の場合
  • top : 上方向へスクロール可能な場合
  • bottom : 下方向へスクロール可能な場合
  • right : 右方向へスクロール可能な場合
  • left : 左方向へスクロール可能な場合
  • y : 上下いずれかの方向へスクロール可能な場合
  • x : 左右いずれかの方向へスクロール可能な場合

また、論理プロパティに相当するblock block-start block-end inline inline-start inline-endも利用可能です。

scrollableを用いると、JavaScriptでスクロール量を計算することなく、スクロール可否によってスタイルを切り替えることができます。

snapped

snappedを指定すると、Scroll Snapによって要素がスナップされているかをもとに判定を行います。性質上、Scroll Snapが有効であることが前提となります。

代表的なユースケースは、スナップされた要素のスタイル変更でしょう。

<div class="container">
  <div class="content">
    <div class="title">1</div>
  </div>
  <div class="content">
    <div class="title">2</div>
  </div>
  <div class="content">
    <div class="title">3</div>
  </div>
  <div class="content">
    <div class="title">4</div>
  </div>
  <div class="content">
    <div class="title">5</div>
  </div>
</div>
/* 見た目の調整用のスタイルは省略 */

.container {
  overflow-y: scroll;
  scroll-snap-type: y mandatory;
}

.content {
  scroll-snap-align: center;

  /* スナップ対象をコンテナとして定義 */
  container-type: scroll-state;

  /* 非スナップ時のスタイル */
  .title {
    font-size: 1em;
    transition: font-size 0.5s;
  }

  /* スナップ時のスタイル */
  @container scroll-state(snapped: y) {
    .title {
      font-size: 2em;
      font-weight: bold;
    }
  }
}

snappedの利用時は、次の点がポイントとなります。

  • スクロールコンテナではなく、スナップ対象をコンテナとして定義する
  • スナップ対象の子要素に対してスタイルを適用する

上記例の場合は、スナップ対象の.content要素をコンテナとして定義し、その子要素である.title要素に対してスタイルを適用しています。

動作例は次のとおりです。

Container Scroll-State Queries "snapped"の動作例(アニメーションGIF)

スナップされた要素のみ、文字のスタイルが切り替わっています。

JavaScriptでScroll Snap Eventsを用いても同様の制御は行えますが、単純なスタイリングであればContainer Scroll-State Queriesのsnappedを用いたほうがシンプルな実装となるでしょう。

stuck

stuckは、position: stickyである要素が、コンテナで固定状態であるかをもとに判定を行います。

position: stickyの代表的なユースケースとして、ヘッダーの固定が挙げられます。Container Scroll-State Queriesのstuckと組み合わせると、ヘッダーが固定状態にある場合のみスタイルを切り替える、といったことが可能となります。

<body>
  <header>
    <div class="headerTitle">Header</div>
  </header>
  <div class="content">1</div>
  <div class="content">2</div>
  <div class="content">3</div>
  <div class="content">4</div>
  <div class="content">5</div>
</body>
/* 見た目の調整用のスタイルは省略 */

body {
  padding-top: 32px;
}

header {
  /* スクロール時にヘッダーを上部で固定 */
  position: sticky;
  top: 0px;
  padding: 16px;
  background-color: white;

  /* コンテナとして定義 */
  container-type: scroll-state;

  /* 非固定状態時のスタイル */
  .headerTitle {
    padding: 8px;
    font-size: 2em;
    transition: all 0.2s;
  }

  /* sticky による固定状態時のスタイル */
  @container scroll-state(stuck: top) {
    .headerTitle {
      border: 2px solid gray;
      border-radius: 8px;
      font-size: 1.2em;
    }
  }
}

snappedと同様、position: stickyである要素をコンテナとして定義する必要があり、かつ、その子要素に対して固定時のスタイルを適用しています。

動作例は次のとおりです。

Container Scroll-State Queries "stuck"の動作例(アニメーションGIF)

スクロールしposition: stickyであるヘッダーが固定状態になると、ヘッダーのスタイルが切り替わっていることがわかります。

ブラウザでのサポート状況

今回紹介した機能について、本記事掲載時点でのメジャーブラウザにおける利用可能バージョンは次のとおりです。これ以降のバージョンであれば利用可能です。

機能 Chrome Edge Safari Firefox
scrollbar-width 121 121 18.2 64
scrollbar-color 121 121 (未対応) 64
scrollbar-gutter 94 94 18.2 97
::scroll-button()擬似要素 135 135 (未対応) (未対応)
::scroll-marker擬似要素 135 135 (未対応) (未対応)
::scroll-marker-group擬似要素 135 135 (未対応) (未対応)
Scroll Snap Events 129 129 (未対応) (未対応)
Container Scroll-State Queries - scrollable 133 133 (未対応) (未対応)
Container Scroll-State Queries - snapped 133 133 (未対応) (未対応)
Container Scroll-State Queries - stuck 133 133 (未対応) (未対応)

まとめ

今回は、スクロール関連の新しい機能を紹介しました。

従来JavaScriptで複雑な実装が必要だったケースの多くが、CSSのみでシンプルに実装できるようになりつつあります。現状では一部のブラウザしか使えない機能も多いですが、今後の仕様策定およびブラウザサポートが進めば、徐々に利用可能なケースも広がっていくでしょう。

すぐに活用はできずとも、知識として持っていると選択肢は広がるかと思いますので、ざっくりとでも押さえておくと良いでしょう。

おすすめ記事

記事・ニュース一覧