画像のCLSが0.25超えてたのでaspect-ratioで押さえ込んだ記録

Lighthouse回してもスコアがずっと赤いままで、CLSが0.25を超えています。RenderingタブでLayout Shift Regionsを有効にしてリロードすると、画像コンポーネントの読み込みが完了したタイミングで下のテキストが押し出される様子が緑のフラッシュで見えます。
原因自体ははっきりしていて、<img> にIntrinsic Size(幅と高さ)が指定されていないことでした。画像データが来るまでブラウザは領域を確保しようがないので、読み込み完了後にレイアウトが再計算されるのも仕様です。それが積み重なってCLSが跳ねているということです。

まずはmin-heightで逃げようとして失敗

最初に思いついたのは親要素に min-height で固定値を当てる方法。
でもこれをやるとレスポンシブ対応が面倒です。ブレークポイントごとに値を書き換えなければならないし、calc()vw の組み合わせで可変にしても、元画像のアスペクト比とコンテナの比率がズレた瞬間に object-fit: cover が効きすぎてトリミングが激しくなるのです。人物の顔が切れるケースが頻発に起きちゃうので採用できないと判断しました。

Next.jsの<Image>は今回は使えない

Next.jsの <Image> なら placeholder="blur" で視覚的なノイズを減らせるし、SSR時に初期HTMLへサイズ情報を埋め込むこともできます。
ただ、今回のプロジェクトはVite + Reactの純粋なSPA構成なので、そのへんの恩恵は受けられなくて。クライアントサイドでレンダリングが走るまで、ブラウザはどうしても画像の寸法を把握できないんです。
フレームワークを変えるのは本末転倒なので、CSS側で解決する方向に切り替えました。

aspect-ratioとグリッドの罠

aspect-ratio で固定比率を確保しようと試みたのがこれです。

.card-image {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}

DevToolsのネットワークスロットリングを「Fast 3G」にしてリロードしてみると、画像リソースが到着する前の段階でブラウザがすでに比率を計算し、グレーのボックス領域を確保してくれてます。CLSのスコアも0.01まで下がりました。

ただここで既存のコンポーネントが邪魔をしてきました。最初は <div className="image-wrapper"> を挟んでそこにスタイルを当てていたんですけど、CSS Gridで grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)) みたいなレイアウトを組んでると、ラッパー要素がグリッドアイテムとして認識されちゃうんですよね。
そうすると意図しないgapの計算が入ったり、配置が崩れたりする。
なので <img> タグのインラインに直接 style を当てられるよう、コンポーネントの forwardRef 実装を見直して、プロパティ透過性を高める改修を入れました。これでグリッドの子要素は <img> そのものになり、レイアウト計算が素直になります。

動的コンテンツで比率がバラバラな場合

ラッパーを外して <img> に直接スタイルを渡せるようになったおかげで、APIから返ってくる画像の縦横比が違う場合も対応しやすくなりました。
固定で 4 / 3 を書くと、正方形に近いサムネやパノラマ画像が潰れちゃうので、インラインスタイルで動的に渡します。

<img style={{ aspectRatio: `${item.width} / ${item.height}` }} />

画像メタデータ(width/height)がDBに保存されている新しいレコードは、これで完璧にレイアウトが維持されます。

レガシーレコードで高さが潰れる

逆に、スキーマ変更以前に登録されたレコードだとメタデータが欠損して null が返ってくることがありました。
このとき aspect-ratio: 0 / 0 あるいは auto として解釈され、要素の高さが潰れて表示が崩れます。

フォールバックとしてCSSの attr() 関数でHTML属性から値を取って計算する手も試したんですけど、現行のCSS仕様では attr() は数値計算に使えないんです。文字列としてのサポートのみで、calc() に渡しても解釈されないので、早めに諦めました。

結局、JS側でフォールバック値を計算してCSS変数経由で渡すアプローチに変更。

<img style={{ '--aspect-ratio': item.width && item.height ? (item.width / item.height) : (16 / 9) }} />

CSS側はこれで受けます。

.card-image {
  aspect-ratio: var(--aspect-ratio, 1.777);
}

未定義のときでもデフォルトの16:9領域は確保できます。フォールバックの分岐がJSに寄るのはちょっともやっとするんですが、CSSだけで完結させるのは現状無理なので割り切りました。

古いブラウザのサポートは切り捨て

Safari 15以前あたりだと aspect-ratio が効かず、縦が0になるリスクがあります。
@supports クエリで囲うか、padding-top ハック(Intrinsic Sizing Technique)をフォールバックに書くべきか迷ったんですけど、コードが冗長になる。

GA4の直近90日のユーザーデータを見ると、Safari 15未満のシェアは0.04%。ビジネス影響度は低いと判断し、あえてサポート対象外としました。ポリフィルを書くコストに見合わないので、Graceful Degradationで許容します。

今のところ

これでCLSの警告は消えました。これから他の画面も直していく感じです。リサイズ時のコストやモーダル周りなど、まだ確認すべき箇所は残ってますが、それはその時とします。