Article

Font Loading API実装でFOIT/FOUTを完全制御

高田晃太郎
Font Loading API実装でFOIT/FOUTを完全制御

HTTP ArchiveやWeb Almanacのレポートでは、Webフォントは多くのサイトで画像・JavaScriptに次ぐ重量級リソースとして扱われています。[1] 一般に3G Fast相当のネットワーク条件では、合計100〜200KB程度のWOFF2フォントを複数読み込むだけで初回テキストが不可視になる時間が発生しやすく、Largest Contentful Paint(LCP)は数百ミリ秒単位で悪化し得ます。[2,9] つまり、たった数本のフォントで、読みやすさとビジネス指標の双方が揺らぐ可能性が高い。従来のCSSだけではユーザーエージェントに委ねられる挙動が多く、制御は限定的でした。そこで鍵になるのがJavaScriptのFont Loading APIです。[4] ロードと適用のタイミングをロジックで握り、見えない時間を短くし、望まない瞬間的なチラつきを避ける。ここでは、FOIT(Flash of Invisible Text: 不可視テキストの点滅)とFOUT(Flash of Unstyled/FOllback Text: 非意図のフォールバック表示)を、プロダクトの責任で扱うための実装を解説します。[3]

FOIT/FOUTの現実を直視し、ビジネスに結びつける

開発チームの感覚では些細な違いに見えるかもしれませんが、フォントの見え方は認知負荷と信頼感に直結します。不可視の状態が長いとユーザーは読み始めを待たされ、読み始めた直後にフォントが切り替わると行送りや文字幅が変わって追随が途切れます。これらは単なる審美性の問題ではありません。フォームの離脱、検索結果ページの再訪率、記事のスクロール深度といった指標に素直に現れます。[9] 公開されている事例やRUM(実ユーザー監視)の一般的な傾向では、Font Loading APIを用いた制御やCSSのメトリクス調整により、スクロール完了率や広告インプレッションなどの指標が改善する可能性が示唆されています。[2,7,9] 改善の内訳は、初回不可視期間の短縮、フォールバックからのスワップ条件の最適化、そして文字幅差による小さなレイアウトシフト(CLS)抑制が中心です。単純なfont-display: swapの一択では、確かに不可視時間は短くなるものの、意図しないタイミングでのスワップが残りやすいのも事実です。[3,2] だからこそ、ロードを手続きとして扱えるFont Loading APIに意味があります。

Font Loading APIの基礎と制御設計

Font Loading APIは、FontFaceとdocument.fonts(FontFaceSet)を軸に構成されます。[4,5] FontFaceはバイナリからフォントを生成してロードし、document.fonts.addで文書に登録できます。FontFaceSetにはreadyというPromiseがあり、フォント群のロード状況を待ち合わせできます。さらに、document.fonts.loadを使えば、特定のフォント名とスタイルで描画に必要なグリフを想定し、実用的なシグナルとして活用できます。[4] 重要なのは、CSSだけに挙動を任せず、不可視時間とスワップの発生条件をビジネス要件に合わせて定義することです。例えば、記事本文の本文フォントは不可視時間を最大300msまで、それを超えたら適切にメトリクスが近いフォールバックを使い続け、初回ビューポートの読了後に静かにスワップする。一方で、ブランドロゴやキービジュアルに使用する表示用フォントは、初回ペイントを妨げないよう必ずフォールバックで出し、その後のアイドル時間で切り替えるといった設計が合理的です。ブラウザ差分については、現行の主要ブラウザはFont Loading APIを実装済みですが、古いSafariなど想定外の環境があるため、feature detectionでdocument.fontsとFontFaceの存在を確認し、未対応環境ではCSSのfont-display戦略にフォールバックする二段構えを勧めます。[6]

CSSだけに頼らない基盤づくり

font-displayは強力ですが万能ではありません。swapやoptionalは不可視時間の短縮に有効な一方で、どの瞬間にスワップが走るかはUAに委ねられる部分が残ります。Font Loading APIによる同期点の設計は、この不確実性を減らします。また、フォントの見た目差によるFOUTの違和感を緩和するには、CSS Fonts Level 4のsize-adjustやascent-override、descent-override、line-gap-overrideの活用が有効です。これらによりフォールバックのメトリクスをターゲットフォントに近づけ、スワップ時の段差や改行位置の移動を最小化できます。[7]

データセーバーや省データ環境への配慮

ユーザーの接続状況やOSの設定に応じて、そもそもフォントをロードしないという選択も合理的です。Font Loading APIの前段でNetwork Information APIのsaveDataフラグや有効帯域を参照し、本文フォントは読み込むが装飾フォントはスキップする、あるいは初回はサブセット版のみを読み込むなどの制御を加えると、LCPとデータ消費のバランスが取れます。[8]

実装パターン集:完全制御のためのコード

まずはHTMLとCSSの基礎を整え、プリロードの誤用で帯域を圧迫しないようにしながら、メトリクスの差を縮める設定を行います。以下は本文フォントのプリロードと、フォールバックの見た目を近づける設定例です。[6]

<!-- head内 -->
<link rel="preload" as="font" type="font/woff2" href="/fonts/Inter-roman.woff2" crossorigin>
<style>
@font-face {
  font-family: 'Inter';
  src: url('/fonts/Inter-roman.woff2') format('woff2');
  font-display: optional;
  size-adjust: 100%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; }
.reading { visibility: visible; }
.reading.foit { visibility: hidden; } /* 必要に応じて短時間だけ不可視化 */
</style>

次に、Font Loading APIをラップした小さなユーティリティを用意します。タイムアウト、キャンセル、エラー処理を最初から組み込み、実運用で壊れない前提を作ります。

// src/font-loader.js (ESM)
export class FontLoadError extends Error { constructor(message, cause) { super(message); this.name = 'FontLoadError'; this.cause = cause; } }

export async function loadFontFromUrl(name, url, descriptors = {}, { timeout = 3000, signal } = {}) {
  if (!('fonts' in document) || !('FontFace' in window)) return false;
  const ac = new AbortController();
  const linked = new AbortController();
  const composite = new AbortController();
  if (signal) signal.addEventListener('abort', () => composite.abort());
  const timer = setTimeout(() => { try { composite.abort(); } catch {} }, timeout);
  try {
    const res = await fetch(url, { signal: composite.signal, cache: 'force-cache' });
    if (!res.ok) throw new FontLoadError(`HTTP ${res.status} for ${url}`);
    const data = await res.arrayBuffer();
    const face = new FontFace(name, data, descriptors);
    await face.load();
    document.fonts.add(face);
    return true;
  } catch (e) {
    if (composite.signal.aborted) return false; // タイムアウトやキャンセルは静かにフォールバック
    throw new FontLoadError('Font load failed', e);
  } finally {
    clearTimeout(timer);
    linked.abort(); ac.abort();
  }
}

export async function ensureUsable(name, testString = '1rem') {
  if (!('fonts' in document)) return false;
  try {
    await document.fonts.load(`${testString} \"${name}\"`);
    return true;
  } catch { return false; }
}

アプリケーション側からはモジュールをインポートして、ビジネス要件に沿った待機上限や適用タイミングを決めます。初回はフォールバックで即時描画し、ヘッドラインやファーストビューが安定してから本文フォントをスワップする、といった制御を明示できます。

// src/main.js (ESM)
import { loadFontFromUrl, ensureUsable } from './font-loader.js';

const root = document.documentElement;
const reading = document.querySelector('.reading');

async function boot() {
  try {
    const saveData = navigator.connection && navigator.connection.saveData;
    const allowFull = !saveData;

    const loaded = await loadFontFromUrl('Inter', '/fonts/Inter-roman.woff2', { style: 'normal', weight: '400' }, { timeout: 1200 });
    const usable = loaded && await ensureUsable('Inter');

    if (usable) {
      root.classList.add('font-ready');
      if (allowFull) reading.classList.remove('foit');
    } else {
      root.classList.add('font-fallback');
    }
  } catch (e) {
    console.warn('Font failed, stay on fallback', e);
    root.classList.add('font-fallback');
  }
}

boot();

CSSだけの構成でもdocument.fonts.loadを利用すれば、不要な不可視を避けながら、ユーザーが読み進める前に切り替える猶予を確保できます。以下は読了を邪魔しないための短い待機と、視覚的な安定のためのクラス切り替え例です。

// src/swap-gently.js (ESM)
export async function swapWhenIdle(fontName = 'Inter', ms = 800) {
  if (!('fonts' in document)) return; // 非対応環境はCSSのfont-displayに委ねる
  const start = performance.now();
  const promise = document.fonts.load(`1rem \"${fontName}\"`);
  const timeout = new Promise(resolve => setTimeout(resolve, ms));
  await Promise.race([promise, timeout]);
  if (document.fonts.check(`1rem \"${fontName}\"`)) {
    document.documentElement.classList.add('font-swap');
  }
  performance.mark('font-swap-mark');
  performance.measure('font-swap-latency', { start, end: 'font-swap-mark' });
}

パフォーマンス計測は改善の前提です。LCPの監視と、独自に定義したFOIT/FOUTの遅延を計測するためにPerformanceObserverを導入し、CDPやRUMに送信して可視化します。以下はLCPとフォントスワップのラグを測る簡易実装です。

// src/metrics.js (ESM)
export function observeLCPAndFontSwap(send) {
  try {
    const po = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType === 'largest-contentful-paint') {
          send({ type: 'lcp', value: entry.startTime });
        }
      }
    });
    po.observe({ type: 'largest-contentful-paint', buffered: true });
  } catch {}

  try {
    const m = performance.getEntriesByName('font-swap-latency');
    if (m[0]) send({ type: 'font_swap_latency', value: m[0].duration });
  } catch {}
}

配信面の最適化も効果が高い領域です。フォントは自己ホストし、強いキャッシュとCORSを適切に設定します。以下はExpressによるヘッダ設定の一例です。[9]

// server/fonts.js (Node.js ESM)
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export function mountFonts(app) {
  const fontsDir = path.join(__dirname, '../public/fonts');
  app.use('/fonts', express.static(fontsDir, {
    setHeaders(res, filePath) {
      if (filePath.endsWith('.woff2')) {
        res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
        res.setHeader('Access-Control-Allow-Origin', '*');
        res.setHeader('Content-Type', 'font/woff2');
      }
    }
  }));
}

最後に、ユーザーの省データ設定を尊重し、装飾フォントを抑制する分岐を入れておきます。これにより帯域の少ない環境でも安定した読みやすさを保てます。[8]

// src/policy.js (ESM)
export function shouldLoadDecorativeFonts() {
  const conn = navigator.connection;
  const saveData = conn && conn.saveData;
  const downlink = conn && conn.downlink;
  if (saveData) return false;
  if (typeof downlink === 'number' && downlink < 1.5) return false; // 1.5Mbps未満なら抑制
  return true;
}

計測結果と運用ガイド:数値で示す改善効果

効果検証は、実装前後のラボ計測とRUMの両輪で進めます。代表的なラボ環境(デスクトップ/モバイル、3G Fast相当)での傾向として、Font Loading APIとsize-adjust系の併用により、初回訪問のLCPや不可視時間が「数百ミリ秒〜約1秒」単位で改善するケースが報告されています。[2,7,9] もちろんサイト構成やフォント数、サブセット有無、キャッシュ状況に強く依存するため、各プロダクトでの再現性は検証が必要です。現実的な運用指針としては、初回ビューポートに現れる本文フォントを対象に、不可視閾値を300ms程度に短く設定し、閾値超過時はフォールバック固定とする方針が扱いやすい。ブランド体験を重視しつつ、読み始めの体験を守るバランスが取りやすいのが理由です。

よくある落とし穴と回避策

プリロードの乱用は最初の帯域を圧迫し、かえってLCPを悪化させます。対象は初回ビューポートに使う書体に限定し、サブセット化や可変フォントの軸圧縮と併用してサイズを削ります。[6] FontFaceを使う際に、CSS側と二重定義して思わぬスワップが走るケースも散見されます。文書登録を行う場合は、CSSの@font-faceでの同名定義と競合しないよう統一するのが安全です。Safariの古いバージョンではreadyの挙動が不安定だった報告があり、loadとcheckの併用で安定化させるとよいでしょう。[5] 障害時に不可視のまま止まる設計は厳禁で、タイムアウトとフォールバック表示の回復線は必ず実装します。

ビジネスへの翻訳:ROIの見積もり

改善の投資対効果は、LCP短縮の売上寄与と、滞在時間や深度の増加から逆算できます。例えば、モバイルセッションのうち記事閲覧が一定割合を占め、記事LCPが0.3秒程度改善し、スクロール完了率がわずかに伸びると仮定する。そこから広告インプレッション増分を見積もり、月間インプレッションとeCPMで増分売上を計算します。ECであれば、LCP改善で商品リストの視認性が上がり、カタログビューからの遷移率の微増を仮定して追加CVを算出し、平均注文単価を掛け合わせれば概算ROIが出ます。Font Loading APIの導入自体はフロントエンド実装の範囲で完結し、配信設定を含めてもスプリント1〜2で立ち上げ可能なため、投資回収の見通しは立てやすい領域です。

まとめ:読み始めを守り、ブランドを活かす

フォントは読みやすさとブランドの両輪であり、見えない時間と望まないスワップを最小化することが、直感的な使いやすさに直結します。Font Loading APIは、その制御権を開発側に取り戻すための実用的な手段です。CSSのfont-displayで不可視を抑え、size-adjust群でメトリクスを整え、document.fontsとFontFaceでロードと適用の同期点を設計する。この連携が、FOITとFOUTを安全に扱うための王道です。まずは初回ビューポートの本文フォントから、不可視の上限とフォールバック固定の方針を決め、計測で効果を確認してみてください。改善の糸口は早期に見えます。次にロゴや見出しなどの装飾フォントへと範囲を広げ、帯域や省データのポリシーを組み込めば、読み始めを守りながらブランドの質感も活かせます。あなたのプロダクトでは、どのテキストがユーザーの最初の一行になるでしょうか。その一行を確実に、そして心地よく届ける設計を、今日から始めてみませんか。

参考文献

  1. HTTP Archive. Web Almanac 2022 – Media
  2. web.dev. Avoid invisible text during font loading
  3. CSS-Tricks. Fighting FOIT and FOUT together
  4. MDN Web Docs. Document: fonts property
  5. MDN Web Docs. FontFaceSet: ready
  6. web.dev. Preload optional fonts to improve loading on slow connections
  7. web.dev. CSS size-adjust
  8. MDN Web Docs. NetworkInformation: saveData
  9. KeyCDN. Web Font Performance