Webフォントで本文がチラつく問題を、font-displayとサブセットで止める
本文が一瞬消える、見出しが後からずれる。FOUT/FOITとCLSの原因をfont-displayの使い分け、日本語フォントのサブセット化、preloadで止めた手順を、コピペできるコード付きで書きました。
公開したばかりのブログを、自分のスマホで開いたときのことです。本文が一瞬だけ表示されて、すぐに真っ白に消えた。コンマ数秒おいて、また文字が戻ってくる。
「壊れた?」と思って何度かリロードしました。壊れてはいなかった。Webフォントが届くのを待つあいだ、ブラウザが本文を隠していただけです。回線の速い自宅PCでは一度も見なかった現象が、地下鉄の電波が弱い場所だと毎回起きる。
文字がチラつくこの現象には名前があって、FOIT(本文が一瞬消える)とFOUT(フォントが後から差し替わる)と呼びます。今日はこのチラつきと、文字が差し替わるときに起きるレイアウトのガクッ(CLS)を、font-display とサブセット化で止めた話を書きます。Astroサイトを例にしますが、考え方はどのフレームワークでも同じです。
この記事の要点
- 文字のチラつきには2種類ある。FOIT(届くまで本文が消える)とFOUT(届いたら差し替わる)。原因は
@font-faceのfont-displayのデフォルト挙動。 font-displayは5つの値があるが、実戦で使うのはswap(本文向き)とoptional(遅い回線で潔く諦める)の2つでほぼ足りる。- 日本語フォントは数MB級。サブセット化(必要な文字だけ抜く)と
unicode-rangeで「かな・英数字だけ先に配る」と初回表示が安定する。 - 差し替え時のガクッ(CLS)は、フォールバックフォントの字幅を
size-adjustで本番フォントに寄せて消す。 - ファーストビューで実際に使う woff2 だけを
preload。先読みしすぎると逆にLCPが遅くなる。
まず「なぜ本文が消えるのか」を分解する
ブラウザはWebフォントをダウンロードし終わるまで、その文字をどう扱うか迷います。この迷いをどう処理するかが font-display で、何も書かないと多くのブラウザは「最大3秒は本文を隠して待つ」挙動になります。これがFOIT、本文が真っ白になる正体です。
逆に「待たずに代替フォントで先に出して、後から差し替える」のがFOUT。差し替わる瞬間に文字の太さや幅が変わるので、見出しの折り返し位置がずれて、その下のレイアウトがガクッと動く。これがCLS(Cumulative Layout Shift、画面のガタつき具合)になります。
| 用語 | 何が起きる | 読者の体感 | 主な原因 |
|---|---|---|---|
| FOIT | 届くまで本文が消える | 「真っ白で読めない」 | font-display 無指定の待ち時間 |
| FOUT | 届いたら差し替わる | 「字がチラッと変わった」 | swap で代替→本番に交換 |
| CLS | 差し替え時に行がずれる | 「読んでた場所が飛んだ」 | 代替と本番で字幅が違う |
ここを分けて理解しておくと、対策が「待ち時間を消すのか」「差し替えを滑らかにするのか」のどちらなのか、毎回はっきりします。僕が最初にハマったのは、この区別をせずに「とりあえず swap」を全部に付けて、今度はCLSが悪化したパターンでした。
font-displayの5つの値を、使うものだけ覚える
font-display には auto / block / swap / fallback / optional の5つがあります。全部覚える必要はありません。実戦で触るのは2つです。
| 値 | 挙動 | 向いている場所 |
|---|---|---|
swap | すぐ代替で表示、届いたら差し替え(FOUTは出る) | 本文、ブランド見出し |
optional | 一瞬だけ待ち、遅ければ代替のまま諦める | 小さなUI、遅延が許せない本文 |
block | 最大3秒隠して待つ(FOITが出やすい) | ほぼ使わない |
fallback | 短く待って差し替え、遅ければ次回まで諦め | 中間的、限定的 |
auto | ブラウザ任せ(多くは block 寄り) | 指定し忘れと同じ。避ける |
判断の軸はシンプルです。ブランド性が大事な見出しは swap(多少チラついても本番フォントを見せたい)。速度最優先で見た目のブレを嫌う本文や小UIは optional(遅い回線では代替フォントのままでいい、と割り切る)。block と auto は基本的に選ばない、という整理で運用しています。
font-display の各値の正確な定義はMDNのfont-display 解説が一次情報です。挙動が曖昧なときは必ずここに戻ります。
日本語フォントは「全部配る」が事故。サブセット化で削る
英語フォントは1ファイル数十KBで済みますが、日本語は漢字を全部入れると数MBになります。Noto Sans JP のフルセットを何も考えずに読み込むと、それだけでLCP(Largest Contentful Paint、画面内の最大要素が出るまでの時間)が一気に悪化します。
効くのがサブセット化、フォントから必要な文字だけを抜き出す作業です。考え方は2段構えにします。
- 見出し・ナビで使うかな・英数字・記号だけを軽いサブセットにして先に配る
- 本文の漢字は、ページから実際に使う文字を抽出して別サブセットにするか、システムフォントに任せる
unicode-range を使うと「このフォントはこの文字範囲だけ担当する」とブラウザに伝えられます。範囲外の文字は読み込みすら走らないので、無駄なダウンロードが減ります。ただし分けすぎるとリクエスト数が増えて逆効果なので、まずは「ラテン+かな+記号」のひと塊から始めるのが扱いやすいです。範囲の指定はMDNのunicode-range 解説を基準にします。
実際のサブセット化は fonttools の pyftsubset でやります。これはコピペで動きます。
# 日本語フォントから「英数字・記号・かな」だけを抜いてwoff2化する
python -m pip install "fonttools[woff]"
mkdir -p public/fonts
# --unicodes で残す文字範囲を指定(ラテン + 約物・かな + 全角英数記号)
pyftsubset ./vendor-fonts/NotoSansJP-Regular.ttf \
--output-file=./public/fonts/noto-sans-jp-latin-kana.woff2 \
--flavor=woff2 \
--layout-features='*' \
--unicodes="U+0000-00FF,U+3000-30FF,U+FF00-FFEF"
# 生成後のサイズを確認(数MB → 数十KB台になっていれば成功)
ls -lh ./public/fonts/noto-sans-jp-latin-kana.woff2
このコマンドは漢字を含めていません。見出しやメニューを軽く配る用です。本文の漢字までWebフォントにそろえたいなら、--text-file=pages.txt のように実際のページから抽出した文字リストを渡して、必要な漢字だけ追加します。とにかく「全グリフを配らない」が日本語フォントの鉄則です。
注意点として、フォントのライセンスによってはサブセット化や再配布が制限される場合があります。作業前にライセンスを必ず確認してください。
CLSを消す:フォールバックの字幅を本番に寄せる
swap を使うと、代替フォントから本番フォントへ差し替わる瞬間に行がずれます。これを消す決め手が、代替フォント側のメトリクス調整です。size-adjust で字幅を、ascent-override などで行の高さを本番フォントに寄せると、差し替えても文字の占める面積がほぼ変わらず、レイアウトが動きません。
次のCSSは、UI用の可変フォント、日本語サブセット、字幅を合わせたフォールバックをまとめた実例です。そのまま public/styles/fonts.css に置けます。
/* public/styles/fonts.css */
/* UI用:可変フォントを1ファイルで100〜900の太さに対応 */
@font-face {
font-family: "InterVariable";
src: url("/fonts/inter-var-latin.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/* 日本語:サブセット化したかな・英数字だけを担当させる */
@font-face {
font-family: "NotoSansJPSubset";
src: url("/fonts/noto-sans-jp-latin-kana.woff2") format("woff2");
font-weight: 400 700;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+3000-30FF, U+FF00-FFEF;
}
/* CLS対策:代替フォントの字幅・行高を本番に寄せて差し替え時のズレを消す */
@font-face {
font-family: "InterFallback";
src: local("Arial");
size-adjust: 107%; /* 字幅をInterに合わせる */
ascent-override: 90%; /* 行の上側の余白 */
descent-override: 22%; /* 行の下側の余白 */
line-gap-override: 0%;
}
:root {
--font-ui: "InterVariable", "InterFallback", system-ui, sans-serif;
--font-ja: "NotoSansJPSubset", "Hiragino Sans", "Yu Gothic", sans-serif;
}
body { font-family: var(--font-ui); }
article { font-family: var(--font-ja); }
size-adjust の数値はフォントごとに変わります。最初は100%で置いて、差し替え前後のスクリーンショットを重ねて、行の折り返し位置がそろうまで5%刻みで詰めるのが現実的です。可変フォント(複数の太さや幅を1ファイルにまとめたフォント)を使うと、400・500・700を別ファイルで読むより @font-face の数が減って管理も楽になります。
preloadは「ファーストビューの1〜2個」だけ
preload は「このリソースを早く取りに行け」という強いヒントです。便利なぶん、付けすぎるとCSSや画像より先に不要なフォントを取りに行って、かえってLCPを遅くします。原則はファーストビューで実際に使う woff2 を1〜2個だけ。
Astroのベースレイアウトなら、こう書きます。Google Fontsなど外部配信を使うときだけ preconnect を足し、セルフホストなら不要です。
---
// src/layouts/BaseLayout.astro
const criticalFonts = [
{ href: "/fonts/inter-var-latin.woff2", type: "font/woff2" },
{ href: "/fonts/noto-sans-jp-latin-kana.woff2", type: "font/woff2" },
];
const usesGoogleFonts = false; // セルフホストならfalseのまま
---
<html lang="ja">
<head>
{/* ファーストビューで使うフォントだけ先読み。crossorigin必須 */}
{criticalFonts.map((font) => (
<link rel="preload" href={font.href} as="font" type={font.type} crossorigin />
))}
{usesGoogleFonts && <link rel="preconnect" href="https://fonts.googleapis.com" />}
{usesGoogleFonts && <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />}
<link rel="stylesheet" href="/styles/fonts.css" />
</head>
<body>
<slot />
</body>
</html>
確認は3点です。href が @font-face の src と完全に一致しているか。as="font" type="font/woff2" crossorigin がそろっているか(フォントの preload には crossorigin が必須です。詳しくはMDNのrel="preload" 解説)。そして、先読みしたフォントが数秒以内に本当に使われるか。Chrome DevToolsで「preloaded but not used」の警告が出たら、その preload は外します。
サイト全体の表示速度を整える話はClaude Codeでパフォーマンス最適化に、画像側のLCP対策はClaude Codeで画像最適化を自動化にまとめてあります。フォントと画像はLCPを取り合うので、合わせて見ると判断が速くなります。
セルフホストかGoogle Fontsか、迷ったときの分け方
@font-face を自サイトの public/fonts から配るのがセルフホスト、Google FontsなどのCSSを読み込むのがサードパーティ配信です。どちらが正解というより、目的で分けます。
| 判断軸 | セルフホスト | Google Fonts等 |
|---|---|---|
| 初回接続 | 自サイトだけで完結 | DNS・TLS・外部CSS取得が増える |
| キャッシュ制御 | 自分で長期キャッシュを設定できる | 提供元のヘッダーに従う |
| サブセット | 自由に作れる | 提供範囲に依存 |
| 運用の手間 | ライセンス・更新・生成を自分で管理 | 導入は速いが外部依存が残る |
僕の結論は、ファーストビューで使うフォントはセルフホスト(preloadとキャッシュを自分で握れる)、装飾的で後から出てもいいフォントは外部配信でも可、という分け方です。外部配信を残すなら、自前CSSと二重に読み込んでいないかだけは必ず確認します。これが地味によくある事故です。
実装後はウォーターフォールで順番を見る
見た目が直っても、ネットワークの読み込み順がおかしいと速度は戻りません。Lighthouseは font-display の警告やLCP・CLSを見つける入口になります。コマンドで回せます。
URL="https://example.com/"
npx --yes lighthouse "$URL" \
--only-categories=performance \
--chrome-flags="--headless" \
--output=json \
--output-path=./lighthouse-fonts.json
# 主要な指標だけ抜き出して表示
node -e "const r=require('./lighthouse-fonts.json'); for (const id of ['largest-contentful-paint','cumulative-layout-shift','font-display']) console.log(id, r.audits[id]?.displayValue ?? r.audits[id]?.score ?? 'n/a')"
数値が取れたら、DevToolsのネットワークタブをウォーターフォール表示にして、HTML→CSS→必要なフォントの順で早く始まっているか、同じwoff2を2回取っていないか、モバイル低速設定でも本文が読めるかを目視します。ラボ計測と実ユーザーの体感はずれるので、公開後はSearch Consoleの実測値も見ます。Core Web Vitalsの定義はweb.devのWeb Vitalsが基準です。
僕が実際に取りこぼした失敗を挙げておきます。preload した太字が本文では一度も使われていなかった。外部CSSとセルフホストCSSの二重読み込みでフォントを2回取っていた。swap を付けただけで size-adjust を入れず、差し替え時にCLSが残っていた。サブセットから長音記号「ー」と全角英数字が抜けて、本文の一部だけ別フォントになっていた。どれも見た目のレビューだけでは見つけにくく、ウォーターフォールと指標で初めて気づきました。
よくある質問
Q. font-display: swap を全部に付ければ解決しますか?
A. FOIT(本文が消える)は消えますが、代わりにFOUT(差し替えのチラつき)とCLSが出ます。swap は size-adjust でフォールバックの字幅を合わせてはじめて完成です。両方セットで考えてください。
Q. 日本語フォントはサブセット化しないとダメですか? A. 本文に大きな日本語Webフォントを使うなら、ほぼ必須です。フルセットは数MBあり、それだけでLCPが悪化します。本文はシステムフォントに任せ、見出しだけサブセットしたWebフォントにするのが軽くて堅実です。
Q. preloadはたくさん付けたほうが速いですよね? A. 逆です。preloadは「他より優先して取れ」という命令なので、付けすぎるとCSSや画像より先に不要なフォントを取りに行き、LCPが遅くなります。ファーストビューで本当に使う1〜2個だけにします。
Q. 可変フォントにすればファイルは必ず小さくなりますか? A. いいえ。複数の太さを1ファイルにまとめる管理上のメリットは大きいですが、収録する軸や文字範囲が多ければサイズは増えます。可変フォントでも、使う文字範囲に応じてサブセット化の検討は必要です。
Q. Google Fontsとセルフホスト、結局どちらが速いですか? A. ファーストビューで使うならセルフホストが有利です。外部接続(DNS・TLS)とCSS取得が1往復減り、preloadとキャッシュを自分で制御できます。導入の速さを優先するならGoogle Fontsでも構いませんが、二重読み込みだけは避けてください。
実際に試した結果
冒頭の「本文がチラつく」問題は、二段階で消えました。まず本文をシステムフォント主体にして、ブランド見出しだけサブセットした woff2 に絞ったら、地下鉄でのFOITが出なくなった。次にフォールバックフォントに size-adjust: 107% を入れて差し替え時の字幅をそろえたら、見出しが届いた瞬間のガクッ(CLS)も止まりました。
意外だったのは、font-display をいじるより先に「何をWebフォントにしないか」を決めるほうが効いたことです。全部を速くしようとすると preload が増えてLCPを食い合う。最初に見える文字だけを確実に早く、残りは潔くシステムフォントに任せる。この割り切りが一番効きました。Lighthouseの数値だけで満足せず、低速モバイルのウォーターフォールと実ページの目視を合わせるのが、結局いちばん確実な確認方法でした。
チームで運用するなら、こうしたフォントのルールを CLAUDE.md に書いておくと再発を防げます。日常コマンドと安全な依頼の型は無料チートシートにまとめてあるので、まずはそこから始めてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。