状態を認識する固定ヘッダー

固定ヘッダーは一般的な UI パターンですが、読みやすさを維持したりスペースを節約したりするために、「固定」されたときに外観を変更する必要があることがよくあります。従来のソリューションでは、JavaScript スクロール リスナーが必要でしたが、これはパフォーマンスの問題を引き起こす可能性があります。最新の CSS では、スクロール状態クエリを使用して、この処理をネイティブに行うことができます。

実装の手順

1. 固定コンテナを作成する

固定要素として機能するコンテナと、スクロール状態クエリのコンテナが必要です。

<!-- Wrap in a section to define the containment area for the sticky element -->
<div class="section">
  <div class="sticky-container">
    <div class="sticky-header">
      Section Header
    </div>
  </div>
  <div class="content">
    <!-- Content goes here -->
  </div>
</div>

2. 固定動作とコンテナタイプに CSS を適用する

container-type: scroll-state は、スクロール可能な祖先要素ではなく、position: sticky 要素に設定します。これらのクエリはネストされる可能性があり、名前のないコンテナが競合するため、常に container-name を指定してください。サイズクエリと組み合わせることもできます(例: container-type: scroll-state inline-size)。

.sticky-container {
  position: sticky;
  top: 0;
  /* MANDATORY: Always specify a container name to avoid collisions if CQs are nested */
  container-type: scroll-state;
  container-name: section-header;
  z-index: 10;
}

.sticky-header {
  /* Base styles for the header */
  background: #f0f0f0;
  padding: 20px;
  transition: background-color 0.3s, box-shadow 0.3s;
}

重要: スクロール状態は、スクロール状態コンテナの子孫によってクエリされます。@container クエリのスタイルは、.sticky-container 自体ではなく、その子(.sticky-header など)に適用されます。コンテナ要素自体を独自のスクロール状態クエリでスタイル設定することはできません。

3. スタック状態をターゲットにする

@container scroll-state(...) クエリを使用して、ヘッダーが固定されたときにスタイルを適用します。

/* Apply styles when the named container is stuck at the top */
@container section-header scroll-state(stuck: top) {
  .sticky-header {
    background: #0056b3;
    color: white;
    box-shadow: 0 4px 6px rgb(0 0 0 / 0.15);
  }
}

ヒント: stuck: inset-block-start や stuck: inset-inline-start などの論理プロパティを使用すると、物理的なエッジではなくフロー相対エッジをクエリすることで、国際化(i18n)と右から左(RTL)のレイアウトをより適切にサポートできます。

ディメンションの変更に関する注意事項

スクロール アンカーを無効にしないと、固定時にレイアウトに影響するプロパティ(高さ、パディング、フォントサイズ)を変更すると、視覚的なちらつきが発生する可能性があります。固定要素の下にあるインフロー コンテンツのスクロール アンカーが、レイアウトの変更を補正するためにスクロール オフセットを調整します。これにより、要素が固定位置から押し出され、振動が発生します。

これを回避するには、固定要素の親でスクロール アンカーを無効にします。

.section {
  overflow-anchor: none;
}

overflow-anchor: none は、:root や body ではなく、position: sticky 要素の直接の親に適用します。 や に適用すると、ページ全体のスクロール アンカーが無効になります。これにより、ボックスモデルのプロパティや変換など、固定状態のプロパティを自由に変更できます。

禁止事項: 下部に固定された要素に overflow-anchor: none を使用する。スクロール アンカーは、現在のスクロール位置より上のレイアウト シフトのみを補正するため、下部に固定された要素には影響しません。下部に固定されたヘッダーの固定状態で、ボックスモデルのプロパティを変更しないようにします。

代替戦略

スクロール状態クエリはプログレッシブ エンハンスメントです。container-type: scroll-state をサポートしていないブラウザでは、ヘッダーは上部に固定されますが(position: sticky による)、視覚的な変換は行われません。ほとんどのユースケースでは、この方法が推奨されます。

固定表示が必須ではないものの、スタイル設定が異なる position: sticky の方が固定表示なしよりもエクスペリエンスが低下する場合は、position: sticky と関連する宣言を @supports クエリでラップすることもできます。

.sticky-container {
  @supports (container-type: scroll-state) {
      position: sticky;
      top: 0;
      container-type: scroll-state;
      container-name: section-header;
      z-index: 10;
    }
}

ヒント: 読みやすさのために「固定」スタイルに別の背景色が必要な場合は、デフォルトのスタイルがどこでも機能するようにレイアウトを設定することを検討してください(プログレッシブ エンハンスメント)。フォールバックを使用する必要がある場合は、@supports (container-type: scroll-state) クエリ内で position: sticky の動作自体をゲートできます。

視覚的な変換がデザインに不可欠な場合(たとえば、固定状態によって背景や圧縮が導入され、それがないとコンテンツが読めなくなる場合)は、IntersectionObserver を使用して堅牢な JavaScript フォールバックを実装できます。.is-stuck クラスで CSS スタイルを複製する必要があります。次の汎用ポリフィルは、getComputedStyle をチェックして上方向にトラバースし、正しいスクロール コンテナ(デフォルトはビューポート)を見つけます。これは @container scroll-state(stuck: top) の動作と一致します。

function getScrollParent(node) {
  if (node == null || node === document.body || node === document.documentElement) {
    return null; // default to viewport
  }
  if (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth) {
    const overflow = getComputedStyle(node).overflow;
    if (overflow !== 'visible' && overflow !== 'clip') {
      return node;
    }
  }
  return getScrollParent(node.parentNode);
}

document.querySelectorAll('.sticky-container').forEach(container => {
  const root = getScrollParent(container);
  
  const topOffset = parseFloat(getComputedStyle(container).top) || 0;
  
  const observer = new IntersectionObserver(
    ([e]) => {
      // Toggle the fallback class on the sticky header container
      e.target.classList.toggle('is-stuck', e.intersectionRatio < 1);
    },
    { 
      root: root,
      threshold: [1],
      rootMargin: `-${topOffset + 1}px 0px 0px 0px` 
    }
  );
  
  observer.observe(container);
});

注: この一般的な IntersectionObserver パターンは、scroll-state(scrollable) クエリのポリフィルとしても使用できます。