1. ホーム
  2. CSS

【CSS】content-visibility の使い方|画面外の描画をスキップして表示を速くする

Share

記事一覧やコメント欄のように縦に長いページは、画面に見えていない部分まで含めてブラウザーがレイアウトと描画を行うため、表示までに時間がかかりがちです。CSS の content-visibility を使うと、画面外にある要素の中身の描画をブラウザーに後回しにしてもらえます。この記事では、3つの値の違い、contain-intrinsic-size でスクロールバーの飛びを防ぐ方法、display: none との使い分けまでを、動かせるデモつきで解説します。

content-visibility は何をするプロパティか

ブラウザーはページを表示するとき、要素の位置や大きさを計算し(レイアウト)、実際にピクセルを塗ります(ペイント)。この計算は、いま画面に見えていない部分に対しても行われます。カードが100枚並ぶ一覧ページなら、最初の数枚しか見えていなくても100枚分の計算が走るわけです。

content-visibility は、「この要素の中身は、いまはまだ描画しなくていい」とブラウザーに伝えるためのプロパティです。スキップされるのはあくまで中身で、その要素自体の箱(枠線や背景)は今までどおり描画されます。中身の計算をまるごと省けるので、要素数が多いページほど初期表示のコストが下がります。

3つの値と効果

指定できる値は3つです。パフォーマンス目的で主役になるのは auto ですが、hidden も表示の出し入れで役に立ちます。

効果
visible初期値。中身をいつもどおり描画する
hidden中身を常にスキップする。表示・非表示を自分で切り替える前提の値
auto画面外などユーザーに関係ない間だけ中身をスキップし、近づくと描画する

auto の「ユーザーに関係ない(relevant でない)」という判定はブラウザーが行います。画面内に入ってきたときはもちろん、フォーカスが当たった、選択された、ページ内検索で見つかった、といったときにも関係ありとみなされ、中身が描画されます。

基本の使い方

使い方は、繰り返し並ぶ要素の1件ずつcontent-visibility: auto; を指定するだけです。一覧全体を包む親要素にひとつ指定しても、その親は画面内に入っているのでスキップされず、ほとんど効果がありません。

style.css
/* 一覧に並ぶカード1枚ずつに指定する */
.article-card {
  content-visibility: auto;
}

これだけでも画面外のカードの描画は省かれます。ただしこのままだと、次に説明するスクロールバーの問題が起きるので、実際には contain-intrinsic-size とセットで使います。

なお content-visibility: auto を指定した要素には、レイアウト・スタイル・ペイントの封じ込め(containment)が適用されます。中身の影響が外に漏れない状態を作ることで、ブラウザーは「この中を計算しなくても外側の表示は変わらない」と判断でき、中身のレンダリングを丸ごと後回しにできるようになっています。さらに中身をスキップしている間は、サイズの封じ込めも加わります。

contain-intrinsic-size で高さを見積もる

中身をスキップしている間はサイズの封じ込めが働くため、その要素の中身の高さは 0 として扱われます。つまり content-visibility: auto だけを指定すると、画面外のカードがぺしゃんこに潰れ、ページ全体の高さが実際よりずっと短くなります。スクロールするたびに要素が本来の高さを取り戻してページが伸びるので、スクロールバーが伸び縮みし、スクロール位置が飛ぶように感じられます。

これを防ぐのが contain-intrinsic-size です。「スキップしている間はこのくらいの大きさだと思っておいて」という仮のサイズを教えておけば、画面外の要素も場所を確保したままになり、ページの高さが安定します。

style.css
.article-card {
  content-visibility: auto;

  /* スキップ中に使う仮のサイズ(実際の高さに近い値を入れる) */
  contain-intrinsic-size: auto 320px;
}

長さを1つだけ書くと幅と高さの両方に適用されます。ブロック要素の幅は親要素によって決まるのが普通なので、実質的に効いてくるのは高さだと考えて構いません。幅と高さを分けたいときは contain-intrinsic-size: 200px 320px; のように「幅 高さ」の順で2つ並べます。

auto を付けて実際のサイズを覚えてもらう

上の例で長さの前に書いた auto は、「一度描画したときの実サイズを記憶して、次からはそちらを使う」という指定です。auto 320px と書いた場合、まだ一度も描画していない要素は 320px として扱われ、一度画面に入って本当の高さが 480px だと分かった要素は、次にスキップされるときに 480px を使ってくれます。

カードの高さが中身によってバラバラなときほど効果があり、最初の見積もりが多少ずれていても、一度スクロールして通り過ぎた範囲ではズレが起きなくなります。特に理由がなければ auto を付けておくのがおすすめです。

デモで違いを確かめる

言葉だけだと分かりにくいので、実際に見てみましょう。左右どちらも同じカードを40枚並べたスクロール枠で、カードには content-visibility: auto を指定しています。違いは右側だけ contain-intrinsic-size を指定している点だけです。枠の下には、その枠がスクロールできる全体の高さ(scrollHeight)を表示しています。

<div class="demo">
  <div class="col">
    <p class="label">contain-intrinsic-size なし</p>
    <div class="scroller" id="plain"></div>
    <p class="info" id="plainInfo"></p>
  </div>
  <div class="col">
    <p class="label">contain-intrinsic-size あり</p>
    <div class="scroller sized" id="sized"></div>
    <p class="info" id="sizedInfo"></p>
  </div>
</div>
.demo {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
  font-family: sans-serif;
}

.label {
  margin: 0 0 6px;
  font-size: 12px;
  font-weight: bold;
  color: #0a58ca;
}

.scroller {
  height: 200px;
  overflow-y: auto;
  padding: 8px;
  border: 2px solid #007bff;
  border-radius: 8px;
}

.card {
  /* 画面外に出ている間はレンダリングをスキップする */
  content-visibility: auto;

  margin-bottom: 8px;
  padding: 10px;
  border: 1px solid #cfe2ff;
  border-radius: 6px;
  background: #e7f1ff;
  font-size: 13px;
  line-height: 1.5;
  color: #0a58ca;
}

/* 右側だけ「スキップ中の推定サイズ」を教えておく */
.sized .card {
  contain-intrinsic-size: auto 61px;
}

.info {
  margin: 6px 0 0;
  font-size: 12px;
  color: #555;
}
// 両方の枠に同じカードを 40 枚ずつ作る
function build(id) {
  const box = document.getElementById(id);
  for (let i = 1; i <= 40; i++) {
    const card = document.createElement('div');
    card.className = 'card';
    card.innerHTML = '<strong>記事 ' + i + '</strong><br>ダミーの説明文です。';
    box.appendChild(card);
  }
}

// スクロールできる全体の高さ(scrollHeight)を表示する
function report(id, infoId) {
  const box = document.getElementById(id);
  const info = document.getElementById(infoId);
  const update = () => {
    info.textContent = 'スクロール全体の高さ: ' + box.scrollHeight + 'px';
  };
  box.addEventListener('scroll', update);
  update();
}

['plain', 'sized'].forEach((id) => {
  build(id);
  report(id, id + 'Info');
});
Preview

左の枠をスクロールすると、表示される高さの数値がどんどん増えていくのが分かります。画面外のカードが 0 の高さに潰れていて、スクロールして近づくたびに本来の高さに戻るためです。スクロールバーのつまみが縮んでいく様子や、スクロール位置がわずかに引っかかる感じも確認できるはずです。一方、右の枠は最初から40枚分の高さが確保されているので、数値もスクロールバーもほとんど動きません。CSS タブの contain-intrinsic-size の行を消したり値を変えたりすると、挙動の変化がそのまま反映されます。

hidden 値と display: none・visibility: hidden の違い

content-visibility: hidden は、画面内かどうかに関係なく中身を常にスキップします。中身が見えなくなるという点では display: none と似ていますが、消え方が違います。display: none は箱ごと消えて場所も取らないのに対し、hidden は要素の箱自体は残るため、contain-intrinsic-size を指定していればその分の場所を取り続けます。

指定場所中身の扱い
display: none取らない描画されず、描画の状態も破棄される
visibility: hidden取る見えないだけで、中身は描画対象のまま
content-visibility: hidden取る(サイズは指定次第)描画をスキップしつつ、描画の状態は保持される

実務で効いてくるのは最後の「描画の状態が保持される」という点です。display: none から元に戻すと、ブラウザーは中身のレンダリングを一から作り直します。content-visibility: hidden は中身の描画状態を保ったままスキップしているだけなので、JavaScript で表示に戻したときの描画が速く済みます。タブ切り替えのパネルやアコーディオンのように、同じ要素を何度も出したり隠したりする場面に向いています。

style.css
/* 隠しているパネル。中身の描画はスキップされる */
.panel.is-hidden {
  content-visibility: hidden;
}
script.js
const button = document.querySelector('.toggle');
const panel = document.querySelector('.panel');

button.addEventListener('click', () => {
  // クラスを付け外しするだけで表示・非表示が切り替わる
  panel.classList.toggle('is-hidden');
});

ページ内検索とアクセシビリティへの影響

auto でスキップされている中身は、「いまは描画していない」だけで、なくなったわけではありません。ページ内検索(Ctrl + F)の対象になりますし、Tab キーによるフォーカス移動やページ内リンクの飛び先にもなります。見つかった時点でその要素は関係ありとみなされ、中身が描画されて表示されます。アクセシビリティツリーにも残るので、スクリーンリーダーからも読めます。記事一覧やコメント欄のように「読ませたいコンテンツ」に付けても、検索性や読み上げが損なわれない設計になっているわけです。

一方 hidden は「見せないための値」なので扱いが逆です。中身はページ内検索に引っかからず、フォーカス移動の対象にもならず、支援技術からも見えません。JavaScript で表示に戻すことが前提の値だと考えてください。読ませたい内容を hidden で隠したままにするのは避けましょう。

どこに使うと効果があるか

向いているのは、縦に長く続く繰り返し要素です。記事一覧のカード、コメント欄の1件ずつ、FAQ のブロック、長いドキュメントのセクションなどが典型例で、1件あたりの中身が重い(画像やテキストが多い)ほど効果が出ます。逆に、ファーストビューに入っている要素はそもそもスキップされないので付けても意味がなく、封じ込めによる副作用だけが残ります。数が少ないページや軽いページで無理に使う必要はありません。

ブラウザーの対応は、Chrome と Edge が早くから対応しており、Firefox と Safari も対応済みです(Safari は 18 から対応しました)。対応していないブラウザーではプロパティが無視され、これまでどおり全部が描画されるだけなので、表示が壊れる心配はありません。まずは付けてみて、速くなれば得をする、という気軽な使い方ができるプロパティです。

思ったとおりに動かないときに見るところ

content-visibility は「動いているのに変化が見えない」「速くするつもりが使い勝手を悪くした」という状態になりやすいプロパティです。よくある3つのケースを見ていきます。

スクロールバーが暴れる・スクロール位置が飛ぶ

ほぼ確実に contain-intrinsic-size の指定漏れです。スキップ中の要素は高さ 0 として扱われるため、スクロールするたびにページ全体の高さが変わってしまいます。実際の高さに近い値を auto 付きで指定してください。値が実際とかけ離れているとズレが目立つので、DevTools で要素の高さを測って平均的な値を入れるとよいでしょう。auto を付けておけば、一度表示した要素については実サイズが記憶されるため、ズレは初回だけに抑えられます。

効果が体感できない

まず、指定した場所を見直します。一覧全体を包む親要素にひとつだけ指定していると、その親は画面内にあるので何もスキップされません。カード1枚ずつのような単位で指定できているか確認してください。次に、そもそも要素数が少ない、あるいは1件が軽いページでは差が出ません。content-visibility が減らすのは画面外の要素の描画コストなので、画面外に大量の中身があって初めて効きます。

体感ではなく数字で確かめるなら、ブラウザーの DevTools のパフォーマンス計測でページ読み込みを記録し、レンダリング(Layout や Paint)に使われた時間を、指定あり・なしで比べます。差が出るのは主に最初の表示なので、キャッシュを無効にして条件を揃えて測るのがコツです。

はみ出した装飾が切れる・絶対配置がずれる

これは封じ込めの副作用です。content-visibilityautohidden を指定した要素はペイントの封じ込めが働き、中身がその要素の内側にクリップされます。要素の外にはみ出させていたドロップシャドウやツールチップ、ホバー時のポップアップなどが途中で切れてしまうことがあります。

また、封じ込めが適用された要素は絶対配置の基準(含みブロック)になり、新しいスタックコンテキストも作ります。そのため position: absoluteposition: fixed を使った子要素の位置が変わったり、z-index による重なり順が思ったとおりにならないことがあります。はみ出す装飾を持つ要素には指定せず、ひとつ内側の要素に付けるなど、指定する場所をずらして回避しましょう。

まとめ

content-visibility: auto は、画面外にある要素の中身の描画をブラウザーに省いてもらうプロパティで、縦に長い繰り返し要素の1件ずつに指定するのが基本の使い方です。スキップ中の高さは 0 として扱われるため、contain-intrinsic-size: auto 320px; のように仮のサイズをセットで指定して、スクロールバーが暴れるのを防ぎます。auto を付けておくと一度描画した実サイズを記憶してくれます。hidden は常に中身をスキップする値で、描画の状態を保持するぶん display: none より再表示が速く、JavaScript で出し入れするパネルに向いています。効果が見えないときは、指定の単位と画面外の要素量を見直してみてください。

参考ページ