フロントエンドで画像を使うときに注意すべきこと
# フロントエンド最近、自分の blog の画像を見直していて、多くの記事がまだ <img src="..."> をそのまま置いているだけで、width、height を設定していないことに気づいた。スマホで開くと、画像の読み込みに合わせてコンテンツが下にずれていく。
この現象は Layout Shift と呼ばれる。画面上の予期しない位置ずれのことだ。よくある原因は、画像自体が大きすぎる、あるいは回線速度が追いつかないことで、ブラウザが画像サイズを知らないまま先にレイアウトを組み、画像が読み込まれたあとで改めて調整することにある。意図的に layout shift を発生させて、ユーザーに広告を誤クリックさせるサイトも見たことがある。こういう設計が本当に「故意」かは意見が分かれるだろうが、読者の体験が悪いのは確かだ。
ちょうど Jake Archibald が記事でレスポンシブ画像のベストプラクティスを整理していたので、僕もついでに自分の考えとやり方をまとめてみる。ちなみに、僕は Jake が司会を務める HTTP203(Chrome for Developers 上の番組だ)がかなり好きで、ブラウザやWebに関する深い解説が多い。
なぜ width と height を必ず付けるのか
よく「CSS で画像の幅はもう指定しているのだから、HTML の width height は重複では?」と思う人がいる。しかも固定値を書き込むのは柔軟性がない感じがするので、いっそ書かないという判断をしがちだ。
<img src="hero.jpg" alt="hero" />
この書き方の問題は、画像が読み込まれるまでブラウザがその画像の大きさをまったく知らないことだ。そのため、いったん高さ 0 としてレイアウトを組む。画像のダウンロードがある程度進んで、画像本体のサイズ情報を取得できると、そこで初めて対応する空間を確保し直す。
すると画面が突然下にずれる。これが CLS(Cumulative Layout Shift) で、Web Vitals の中でも SEO のスコアに直接影響する指標のひとつだ。
正しい書き方はこうだ。
<img src="hero.jpg" width="1600" height="900" alt="hero" />
width="1600" height="900" と書いておけば、ブラウザは HTML を見た時点でこの画像の縦横比が 16:9 だと分かる。たとえ CSS 上では width: 100% にしていても、ブラウザはその比率から高さを計算し、必要な空間を先に確保できる。画像の読み込みが終わっても、画面は跳ねない。
HTML 上の width height は実寸ではなく、ブラウザに aspect ratio を計算させるためのものだ。実際の表示サイズは CSS が引き継ぐ。
img {
width: 100%;
height: auto;
}
2020 年以降、Chrome、Firefox、Safari は width と height 属性から CSS の aspect-ratio1 を自動的に推論する。つまり、裏ではだいたい次のようなことをしてくれるのと同じだ。
img[width][height] {
aspect-ratio: attr(width) / attr(height);
}
だから、width と height をきちんと入れておけば、CSS 側では好きに縮小・拡大できるし、CLS も同時に解決できる。レイアウトが本当に動的で、比率すら分からない場合を除けば(そういうケースの対処は後で触れる)、デフォルトではこの 2 つの属性を付けるべきだ。(とはいえ、僕もついサボりがちだけど)
CSS の aspect-ratio はいつ使うのか
画像の実寸が分からない場面もある。たとえば、画像 URL が動的だったり、CMS がサイズ欄を返してくれなかったり、<div> に background-image を使ってカバー画像を作っていたりする場合だ。そのときに出番になるのが CSS の aspect-ratio だ。
.cover {
aspect-ratio: 16 / 9;
width: 100%;
background-size: cover;
}
aspect-ratio は画像の比率を固定したいときにとても便利だ。正方形カード、動画のサムネイル、<iframe> を 16:9 で包むときなどによく使う。昔は padding-top: 56.25% のような hack が必要だったが、今は一行で済む。
画像に話を戻すと、原則はこうだ。
- 画像の実寸が分かっている → HTML の
widthheightに書く - 分からないが、表示したい比率は決められる → CSS の
aspect-ratioを使う - 2 つは衝突しないので、併用してもよい
形式を変える:WebP と AVIF
JPEG は 1992 年の規格で、僕よりも古い。現代の形式なら、かなり小さくできる。
- WebP — Google が推進した形式で、2010 年に公開された。同じ品質なら JPEG よりファイルサイズを小さくできる2。主要ブラウザはすべて対応している3
- AVIF — AV1 動画コーデックをベースにした画像形式で、2019 年の規格。写真系の画像では、中〜高品質帯で JPEG よりさらに小さくなることが多い4。Safari は 16 から対応し、Chrome と Firefox はそれ以前から対応しているため、現在は主要ブラウザをほぼカバーしている5
ついでに JPEG 圧縮の裏側 も読むと、画像圧縮の原理を理解しやすい。
僕自身の今の運用は、AVIF が使えるなら AVIF、WebP をバックアップ、JPEG/PNG は最終 fallback だ。実際の圧縮差は画像の内容によってかなり変わる。色面が多くグラデーションが少ない画像(たとえば UI のスクリーンショット)は WebP/AVIF の効果が非常に高いし、高周波ディテールが多い画像(たとえば風景写真)は差が小さくなることもあるが、それでもたいていは使う価値がある。
形式の fallback を実現するには <picture> を使う。
picture、source、srcset の組み合わせ
<picture> の考え方は単純だ。いくつかの <source> を並べて、ブラウザが上から順に最初に対応しているものを選び、対応していなければ <img> に fallback する。
<picture>
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<img src="hero.jpg" width="1600" height="900" alt="hero" />
</picture>
ここまでで形式の問題は解決したが、サイズの問題はまだ残っている。スマホ画面が 400px 幅しかないのに、1600px 幅の画像をダウンロードするのは帯域の無駄だ。そこで srcset と sizes の出番になる。
srcset で複数解像度を渡す
srcset には 2 種類の書き方があり、違いは descriptor にある。
wdescriptor: 画像の実際の幅(ピクセル)を示す。sizesと組み合わせると、ブラウザがレイアウト幅と DPR に応じて自分で選ぶxdescriptor: この画像が何倍 DPR 向けかを示す。同じ表示サイズで Retina だけに対応したい場面に向いている
x descriptor はこんな感じで、比較的単純だ。
<img
src="avatar.jpg"
srcset="avatar.jpg 1x, avatar@2x.jpg 2x, avatar@3x.jpg 3x"
width="80"
height="80"
alt="avatar"
/>
ただし、表示幅が画面によって大きく変わる画像(たとえばスマホでは全幅、デスクトップでは固定 800px)では、x descriptor だけでは不十分だ。w descriptor と sizes を使う必要がある。
<img
src="hero-800.jpg"
srcset="
hero-400.jpg 400w,
hero-800.jpg 800w,
hero-1600.jpg 1600w
"
sizes="(max-width: 600px) 100vw, 800px"
width="1600"
height="900"
alt="hero"
/>
sizes は「この画像がレイアウト上で実際にどれくらいの幅で表示されるか」をブラウザに伝えるためのものだ。上の例ではこういう意味になる。
- 画面幅が 600px 以下なら → 画像は 100vw(画面幅いっぱい)
- それ以外なら → 画像は固定で 800px
ブラウザはこの情報と、デバイスの DPR(device pixel ratio、物理ピクセルと CSS ピクセルの比率)を踏まえて、srcset から最適な画像を選ぶ。たとえば iPhone の画面幅が 390px、DPR が 3 なら、必要な解像度は 390 × 3 = 1170px になるので 1600w を選ぶ。デスクトップで幅 800px、DPR が 2 なら 1600px が必要なのでやはり 1600w。もしデスクトップ幅 800px、DPR が 1 なら 800w で十分だ。
形式とサイズを一緒にする
<picture> と srcset を組み合わせると、こうなる。
<picture>
<source
type="image/avif"
srcset="hero-400.avif 400w, hero-800.avif 800w, hero-1600.avif 1600w"
sizes="(max-width: 600px) 100vw, 800px"
/>
<source
type="image/webp"
srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1600.webp 1600w"
sizes="(max-width: 600px) 100vw, 800px"
/>
<img
src="hero-800.jpg"
srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw, 800px"
width="1600"
height="900"
alt="hero"
/>
</picture>
各 <source> は、同じ画像の異なる形式におけるすべてのサイズだ。つまり実際には、3 形式 × 3 サイズ = 9 ファイルを生成することになる。ここは通常、build tool や CDN に任せる。よくある選択肢としては Astro の <Image>、Next.js の next/image、あるいは画像 CDN サービスがある。
僕はいま Cloudflare Images をよく使っている。事前に同じ画像を AVIF/WebP/JPEG の複数バージョンにしてアップロードする必要がなく、request の Accept header を見てブラウザが対応する形式を判断し、その場で on-demand に変換して返してくれる。しかも CDN キャッシュも維持される。build 時点でいろいろな画像を生成するより、動的な画像変換のほうが余計な手順を増やさなくて済む。
モバイルで別の画像に差し替える(art direction)
ここまで話したのは、同じ画像を異なる解像度で出し分ける方法だ。しかし場合によっては、モバイル版とデスクトップ版で「まったく別の画像」にしたいことがある。これを art direction と呼ぶ。
たとえば、デスクトップ版では 16:9 の横長風景写真を使っているが、スマホでは全体が小さくなって主役が見えにくい場合だ。より良い方法は、スマホでは主役が大きく見えるように、トリミングした縦長画像に差し替えることだ。
<picture> のもうひとつの用途がこれだ。<source> に media 属性を付ける。
<picture>
<!-- 手機版:直幅裁切 -->
<source
media="(max-width: 600px)"
type="image/avif"
srcset="hero-mobile-400.avif 400w, hero-mobile-800.avif 800w"
sizes="100vw"
/>
<source
media="(max-width: 600px)"
type="image/webp"
srcset="hero-mobile-400.webp 400w, hero-mobile-800.webp 800w"
sizes="100vw"
/>
<!-- 桌機版:橫幅原圖 -->
<source
type="image/avif"
srcset="hero-800.avif 800w, hero-1600.avif 1600w"
sizes="800px"
/>
<source
type="image/webp"
srcset="hero-800.webp 800w, hero-1600.webp 1600w"
sizes="800px"
/>
<img
src="hero-800.jpg"
width="1600"
height="900"
alt="hero"
/>
</picture>
<source> はやはり上から順に最初に一致したものを選ぶので、media が付いたものは上に置く必要がある。モバイル版が先に前面に出て、デスクトップ版はその後にマッチする。
ひとつ細かい点がある。モバイル版の画像は縦長で、aspect ratio が <img> に書いた 16:9 と異なることがある。その場合は、CSS の aspect-ratio でコンテナの比率を統一して管理するか、ブレークポイントごとに CSS で切り替えるほうが、<img> の固定属性に頼るより安定する。
object-fit で仕上げる
確保した空間の比率と、画像そのものの比率が一致しないとき(たとえばカードを強制的に 1:1 にしているが、画像は 16:9 など)、object-fit を使う。
img {
width: 100%;
height: 100%;
object-fit: cover;
}
object-fit は <img> と <video> の両方に効く。よく使う値は 2 つある。
- cover — コンテナを埋める。画像は切り抜かれる。カードのサムネイルや顔写真でよく使う
- contain — 画像を全部表示する。コンテナに余白ができる。logo や商品画像のように「切り抜けない」ものに使う
object-position を組み合わせると、切り抜きの基準位置を調整できる。たとえば object-position: top とすれば上から切り抜くので、人物のあごが切れにくくなる。
loading と fetchpriority
サイズや形式以外にも、今では標準装備に近い属性が 2 つある。
<img
src="hero.jpg"
width="1600"
height="900"
loading="lazy"
decoding="async"
alt="hero"
/>
loading="lazy"— 画像が viewport に入ってから読み込む。長文記事、商品一覧、瀑布流で特に効果があり、初期読み込みの通信量を大きく減らせるdecoding="async"— 画像の decode が main thread をブロックしない。デフォルトの挙動もかなり async に近いが、明示しておくと一部ブラウザの同期 decode 経路を避けやすいfetchpriority="high"— 逆に、重要な画像にはこれを付けてブラウザに優先読み込みさせる
最近のブラウザは画像最適化の仕組みをかなり多く備えているので、昔のように複雑な JavaScript で自前最適化する必要はない。前端がやるべきことは、必要な属性をきちんと付けることだ。残りはブラウザに任せればいい。
Footnotes
関連記事
- CSS field-sizing — 1行のCSSでフォーム要素を自動リサイズさせるtextarea の自動高さ調整は、以前は scrollHeight を監視する JavaScript が必要だった。CSS field-sizing: content なら1行で済む。textarea・input・select に対応。
- 超リンクの下線をもっときれいに見せる:text-underline-offsetデフォルトでは下線が文字にかなり近く、こういう見た目を好まないデザイナーもいる。僕自身も、あまりきれいだとは思っていない。
- なぜウェブは Pixel Perfect を追求すべきではないのかPixel Perfect が本当に重要なときだけそれを気にすべきであり、そうでなければ往々にして双方にとって損な結果になる。
- CSS の HSL で色を書こう!(そしてより良い方法)Web開発において、従来の HEX や RGB の色表現は広く使われているものの、読みやすさや直感性に欠ける問題があり、P3 のような広色域では表現力にも限界がある。HSL(色相、彩度、明度)は、より直感的に色を定義できる方法であり、開発者が色を理解し調整しやすくしてくれる。HSL は色相・彩度・明度の3つの軸で色を表すため、特にデザインシステムにおいて、カラーパレットの明度変化をより自然に扱える。