レスポンシブ対応の考え方と実装
はじめに
ウェブでレスポンシブ対応を行う際の意識すべき点をまとめてみました。ウェブフロントエンドやマークアップを学習中の方の参考になれば幸いです。私自身、感覚的にコーディングしている部分もありますので、より良い方法や間違っている部分などがあれば、指摘していただけると嬉しいです。
記事の構成として、レスポンシブ対応の大枠の考え方から始まり、徐々に粒度を下げて個別の実装に向かうという流れになっています。
レスポンシブはメディアクエリだけではない
「レスポンシブ対応 やり方」で検索すると、メディアクエリの使い方を説明するものが多くヒットします。メディアクエリはレスポンシブを実現する手段の一つですが、それが全てではありません。
ウェブの世界では、画面のサイズは切れ目なく変化します。同じデバイスでも、ウィンドウサイズ(PC の場合)や端末の向き(モバイルやタブレットの場合)により、画面サイズは大きく変化します。そのため、どのような画面サイズでも破綻なく表示される必要があります。
重要なことは、PC・タブレット・モバイルの 3 種類のデザインを用意することではなく、どのようなサイズの画面でも見やすいデザインを実装することです。
1. レスポンシブ対応しやすいデザインを考える
もし個人開発など、実装者にデザインの決定権がある場合、レスポンシブ対応しやすいデザインを採用することで、実装量を削減できます。
カラム数を考える
3 カラムレイアウト
ウェブページの代表的なレイアウト構成に、3 カラムレイアウトがあります。画面を縦に三分割し、メインコンテンツを中央に、ナビゲーションや目次などを両側に配置する構成です。MDN や Twitter で採用されています。
3 カラムレイアウトは馴染みのある構成ですが、画面幅に応じて表示するカラムやレイアウトを出し分ける必要があります。
最近では Grid レイアウトで簡単に実装できるようになってきましたが、それでも一定の対応コストがかかります。
1 カラムレイアウト
1 カラムレイアウトは、画面を縦に分割することなく、メインコンテンツのみを中央に表示する方法です。zenn のトップページや note の記事ページ、Google の検索結果画面などが代表的です。レスポンシブ対応を行う際には、画面幅に応じて、余白を変化させるだけで済みます。
2 カラムレイアウト
両者の中間に位置するのが 2 カラムレイアウトです。zenn や Qiita の記事ページがこのレイアウトです。3 カラムレイアウトよりブレイクポイントが少ないため、レスポンシブ対応は比較的簡単になるでしょう。
2. どこが可変でどこが固定かを明確にする
冒頭で述べたように、ウェブの世界において画面サイズは切れ目なく変化します。サイズの変化を、UI のどの部分で吸収するのかを意識することが重要です。
両側の余白で吸収する
最も簡単な例は、コンテンツの左右の余白で画面幅の変化を吸収する方法です。
CSS では、width
の指定と margin: 0 auto;
、または Flex レイアウトなどで実現できます。
下記は width
と margin
の指定で実装した例です。margin
に auto
を指定することで、両側に余白を均等に割り付けることができ、結果的にコンテンツが中央に表示されるようになります。
<body>
<div class="container">メインコンテンツ</div>
</body>
.container {
margin: 0 auto; /* 水平方向のmarginをautoに */
width: 800px;
}
コンテンツの幅で吸収する
コンテンツの幅を変化させる方法もあります。グリッドレイアウトや横スクロールを採用しているデザインに多く、常に画面幅いっぱいにコンテンツを配置します。メインコンテンツを伸縮させることで画面幅の変化を吸収します。
グリッドレイアウトの利用
続いて、コンポーネント単位で画面幅の変化を吸収させる方法です。
Grid レイアウトは、子要素をグリッド上に並べることができるレイアウト方法です。これを用いて、画面幅の変化に強いスタイリングを行うことができます。
Grid レイアウトでは、grid-template-columns: repeat(auto-fit, 200px);
を指定することで、200px の Grid アイテムを自動的に並べてくれます。溢れた分は自動的に折り返されます。さらに、minmax
を利用することで、各 Grid アイテムを適宜拡大し余白を埋めることができます。
詳しくはこちらの記事が分かりやすいです。
.container {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
画面幅に応じて、Grid アイテムが自動で拡大・折り返しされます
全体の実装を codepen で確認する
リンク
部分的に可変にする
多くのコンポーネントは、固定幅のパーツと可変幅のパーツが組み合わせて実装されています。例えば以下の UI では、アイコンのサイズを固定とし、テキスト部分を可変とすることで描画エリアの幅の変化を吸収しています。
アイコンサイズは固定のまま、テキスト部分が伸縮します
このような実装は、Flex レイアウトと flex-shrink
、flex-grow
を利用することで実現できます。
プロパティ | 説明 |
---|---|
flex-shrink: 0; |
縮小を行わない部分に適用(固定幅) |
flex-grow: 1; |
余白があれば拡がる部分に適用(可変幅) |
<div class="container">
<div class="icon"></div>
<div class="text">
...
</div>
</div>
.container {
display: flex;
width: 100%;
}
.icon {
flex-shrink: 0; /* 縮小しない */
}
.text {
flex-grow: 1; /* 余白があれば拡がる */
}
全体の実装を codepen で確認する
3. メディアクエリ・コンテナクエリの利用
上記の方法で画面幅を吸収しきれない場合、メディアクエリやコンテナクエリの使用を検討します。これらを利用することで、画面幅やコンテンツ幅に応じて適用する CSS を切り替えることができ、大幅なレイアウトの変更が可能になります。
ここでは、メディアクエリの適用例を 2 つ紹介します。
非表示にする
メディアクエリと display: none;
を利用することで、画面幅に応じて表示・非表示を切り替えることができます。
この例では、モバイル専用のヘッダーを用意し、横幅が 600px を超えた場合にはヘッダーを非表示にする実装を行っています。
<header class="header">
Mobile Header
</header>
<main class="main">
Hello, world!
</main>
.header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
/* 600px以上の場合のみ、適用されます */
@media (600px <= width) {
.header {
display: none; /* 非表示 */
}
}
画面幅を拡大すると、ヘッダーが非表示になります
全体の実装を codepen で確認する
並び方向を変更する
「部分的に可変にする」で実装した UI ですが、コンテンツ幅がさらに狭くなるとテキスト部分の可読性が落ちてしまいます。今回は、アイコンとテキストの並び順を変更することで、テキスト部分の可読性を維持しましょう。
Flex
レイアウトを利用すれば、flex-direction
を変化させるだけで、並び方向を変更できます。このとき、gap
を利用すると、方向によらず余白を一括して指定することができます。
<div class="card">
<div class="inner-card">
<div class="icon"></div>
<div class="text">
Hello, world!
</div>
</div>
</div>
.card {
/* コンテナの設定 */
container: card / inline-size;
}
.inner-card {
display: flex;
width: 100%;
gap: 16px; /* 並び方向によらず、要素間の幅は16px */
}
/* 400px以下の場合 */
@container card (width <= 400px) {
.inner-card {
flex-direction: column; /* 並び方向を変更 */
}
}
全体の実装を codepen で確認する
ここで注目すべき点は、flex-direction
の 1 つのプロパティを変更するだけでレスポンシブ対応を行っている点です。ブレイクポイントを境に適用する CSS が大きく異なってしまう場合、CSS の複雑度が大幅に上がってしまいます。
flex-direction
や gap
を利用することで、切り替えるプロパティを減らすことができ、見通しが良くなっています。(もちろん、変化するプロパティを減らすことが、必ずしも見通しの良さに繋がるとは限りません。)
実装時から、レスポンシブ対応を見越した(レスポンシブ対応しやすい)マークアップを心がけると良いと思います。
メディアクエリとコンテナクエリ
メディアクエリはビューポートサイズに応じて適用する CSS を変更します。一方コンテナクエリは、コンテナのサイズによって適用する CSS を変更します。あるコンテンツの親要素をコンテナとすることで、表示可能領域に応じたレイアウトの変更が可能になります。
クエリ | 説明 |
---|---|
メディアクエリ | ビューポートサイズなどに応じて適用する CSS を切り替える |
コンテナクエリ | コンテナのサイズによって適用される CSS を切り替える |
MediaQuery の Range Syntax
Media Queries Level 4 仕様書で、MediaQuery の指定に不等号が利用できるようになりました (Range Syntax)。
/* 新しい書き方 (Range Syntax) */
@media (600px <= width <= 1024px) {
.header {
display: none;
}
}
/* 従来の書き方 */
@media (min-width: 600px) and (max-width: 1024px) {
.header {
display: none;
}
}
不等号で書けるようになったことで、より直感的な指定が可能になりました。Range Syntax は、コンテナクエリや、window.matchQuery
でも利用できます。
リンク
4. JS での分岐
メディアクエリやコンテナクエリを利用することで、大部分のデザインが実現可能です。しかし、CSS では対応できないケースや、画面幅に応じて実行する処理を変化させる必要がある場合には、JavaScript が用いられます。
JS でメディアクエリを利用する
JavaScript でメディアクエリを利用する場合、window.matchMedia(...)
を利用することができます。CSS と同様にメディアクエリを表す文字列を与えることで、ビューポートがメディアクエリにマッチしているのかを取得できます。
const mediaQuery = window.matchMedia('(width < 600px)');
if (mediaQuery.matches) {
// 600px未満の場合の処理
} else {
// 600px以上の場合の処理
}
通常 JavaScript は読み込み時に一度だけ実行されるため、ユーザーが画面幅を変更した場合には対応できません。これに対応する場合は、MediaQueryList.addEventListener("change", ...)
を利用します。
const mediaQuery = window.matchMedia('(width < 600px)');
const callback = (mediaQueryList) => {
if (mediaQueryList.matches) {
// 600px未満の場合の処理
} else {
// 600px以上の場合の処理
}
};
mediaQuery.addEventListener('change', callback);
window.matchMedia(...)
が返す MediaQueryList
に対してリスナを追加します。メディアクエリのマッチ状態が変化した場合に、コールバック関数が実行されるようになります。
レガシーな方法との比較
従来の書籍や記事では、resize
イベントと window.innerWidth
を利用して画面幅を計算する実装が紹介されていることがあります。
const callback = () => {
if (window.innerWidth <= 600) {
// 600px未満の場合の処理
} else {
// 600px以上の場合の処理
}
};
window.addEventListener('resize', callback);
しかしこの方法では、resize
イベントが発生するたびにコールバック関数が実行されてしまいます。resize
イベントは、ユーザーが画面サイズを変更している間、連続して発火し続けるため、パフォーマンスの低下を招く可能性があります。
MediaQueryList.addEventListener
は、メディアクエリのマッチが変化したタイミングでのみコールバック関数が実行されるため、パフォーマンスの低下を抑えることができます。
リンク
5. はまりどころ
iOS Safari で 100vh を指定してもはみ出てしまう
iOS の Safari は、スクロールに応じてアドレスバーが伸縮します。一方、100vh
は常にアドレスバーが縮んだ状態のビューポートの高さとなるため、アドレスバーが表示されている場合は UI が画面からはみ出してしまう問題が発生していました。これは、新たに dvh
、svh
、lvh
というビューポート単位が導入されたことにより解決されています。
単位 | 説明 |
---|---|
svh |
アドレスバーなどがすべて表示された、最も小さいビューポート(Small Viewport)における高さを基準とした値 |
lvh |
アドレスバーなどが格納された、最も大きいビューポート(Large Viewport)における高さを基準とした値 |
dvh |
アドレスバーの伸縮などに応じて動的に設定されるビューポート(Dynamic Viewport)における高さを基準とした値 |
これらの単位は、現在はすべてのモダンブラウザに実装されていますが、古いブラウザでは動作しないことに注意してください。vh
を同時に記載することで、フォールバックを指定することができます。
body {
height: 100vh; /* dvh 未対応ブラウザ向け */
height: 100dvh;
}
リンク
UI がノッチやアクションバーと被ってしまう
モバイル開発に携わっている方にはお馴染みですが、一部のスマートフォンでは、ディスプレイの角丸やノッチによって画面の一部が隠される場合があります。ノッチなどに UI 部品が被ってしまうと、見づらくなったり、操作ができなくなったりしてしまいます。
セーフエリア
モバイルにはセーフエリアという概念が存在します。これは、ノッチやアクションバーなどで隠されることのない「安全な」矩形のことを指します。
赤い部分がセーフエリアです。環境変数で、端からセーフエリアまでの距離を取得できます。
セーフエリアの情報にアクセスする
セーフエリアの情報を利用するために、safe-area-inset-{top,right,left,bottom}
という環境変数が用意されています。これらはユーザーエージェントで定義されており、CSS からは env()
関数を利用してアクセスできます。
以下の実装例では、セーフエリアの下端から 12px のところにボタンを表示しています。env
の第二引数で、環境変数が利用できなかった場合のフォールバック値を設定しています。
.fab {
position: fixed;
/* セーフエリアの下端から 12px 分のところに表示する */
bottom: calc(env(safe-area-inset-bottom, 0px) + 12px);
left: 12px;
right: 12px;
}
以下は FAB(Floating Action Button)で、セーフエリアを考慮していないもの(左)と考慮したもの(右)の比較です。アドレスバーが縮んでいる場合、左側の FAB はセーフエリア外(赤いエリア)に被ってしまっています。
セーフエリアを考慮しない場合、FAB が iOS のアクションバーに被ってしまう
リンク
子要素が親要素を超えてしまう
PC ベースでスタイリングしていたデザインをモバイルで確認したところ、不要な横スクロールが発生してしまうケースがあります。body やルートとなる要素に width: 100vw;
を指定していても、なお発生する可能性があります。ここでは、代表的な 2 つのケースとその対処法を紹介します。
box-sizing
を変更する
width: 100%;
は、その要素の幅を親要素と同じ幅に設定します。この状態で、水平方向の padding や border を追加した場合、padding や border の分だけ親要素をはみ出してしまいます。
このような場合は、box-sizing: border-box;
を指定することで解決します。border-box
を指定すると、width
やheight
の計算方法が変化ます。 padding や border を含めた合計の要素幅がwidth
や height
として扱われます。
border-box による width, height の計算方法の違い
全体の実装を codepen で確認する
border-box による width, height の計算方法の違い
リセット CSS には、全ての要素をデフォルトで border-box
とする記述が含まれているものも多いです。
tailwindcss に含まれているリセット CSS(Preflight)にも含まれています。
margin
の指定を確認する
box-sizing
を border-box
にすると、border や padding も width
のサイズ内に含まれます。しかし、margin はなお width
のサイズに含まれません。
子要素に指定されたmargin
が原因で、親要素をはみ出してしまう場合があります。
<div class="container">
<div class="item">
Hello, world!
</div>
</div>
.container {
width: 600px;
}
.item {
margin: 12px;
}
このようなケースでは、余白の実現のために、子要素の margin
ではなく、親要素の padding
を利用することをおすすめします。
.container {
width: 600px;
padding: 0 12px; /* 親要素に水平方向のpaddingを追加 */
}
.item {
margin: 12px 0; /* 子要素の水平方向のmarginを削除 */
}
gap を利用する
本筋から離れますが、Flex レイアウトと gap
を利用することで、子要素の margin を完全に無くすこともできます。
.container {
width: 600px;
padding: 0 12px;
display: flex; /* Flexレイアウトを適用 */
flex-direction: column;
gap: 12px; /* gapを追加 */
}
.item {
}
6. Tips
画像の出しわけ
ウェブサイトのパフォーマンスにおいて、画像のサイズがボトルネックとなるケースが多いです。表示画面の小さいモバイル端末では、容量の大きい高精度な画像を表示する必要はありません。そのため、画面幅に応じて画像を出しわけることで、パフォーマンスを向上させることができます。
<picture>
<!-- 1024px以上のサイズで表示される画像 -->
<source srcset="/images/responsive_layout/pc.png" media="(1024px <= width)" type="image/webp" width="300" height="240">
<!-- 800px以上1024px未満のサイズで表示される画像 -->
<source srcset="/images/responsive_layout/tb.png" media="(800px <= width)" type="image/png" width="300" height="240">
<!-- 800px未満のサイズで表示される画像 -->
<img src="/images/responsive_layout/sp.png" width="300" height="240">
</picture>
リンク
clamp
とビューポート単位の利用
vw
, vh
, dvh
などのビューポート単位を利用することで、画面サイズに応じて滑らかに変化するデザインを実現することができます。
例えば、font-size
に vw
を利用することで、画面幅に応じたフォントサイズが指定可能です。
さらに、clamp
を併用することで、最大値と最小値を指定することができます。
この例では、フォントサイズを 6vw
(画面の横幅の 6%) としています。これを、clamp
を利用し、16px を下回る場合は 16px に、32px を上回る場合には 32px に丸めています。
.heading {
font-size: clamp(16px, 6vw, 32px);
}
全体の実装を codepen で確認する
リンク
まとめ
- レスポンシブ対応はメディアクエリだけではない。
- ウェブの世界では、画面サイズは切れ目なく変化することを意識する。
- 画面サイズの変化を、UI のどの部分で吸収するのかを考える。
Discussion
間違えてたらすみません。JSでメディアクエリを利用する部分の
こちらのaddListenerメソッドは非推奨だと思うのでaddEventListenerのchangeイベントを使用したほうがよいかもです。
ご指摘いただきありがとうございます。おっしゃる通りでした!
該当箇所を
addEventListener
に修正し、反映いたしました。記事とても参考になりました!
ありがとうございます!
こちらは、文脈的に、
width: 100vw
でしょうか?ご指摘いただきありがとうございます。おっしゃる通りです!
該当箇所を修正し反映いたしました