レスポンシブCSS設計の考え方:モバイルファースト・clamp・コンテナクエリで崩さない
横スクロールが消えないのは設計順序のせい。モバイルファースト、clamp、コンテナクエリ、画像の出し分けまで、崩れないレスポンシブCSSの考え方を実例で。
「スマホで見たら横スクロールが出てる」
このバグ報告を、僕は何度受け取ったか分かりません。直しても直しても、別の画面でまた出る。あるとき、お客さんのiPhoneで自分のページを開いたら、本文が画面の右にはみ出して、指でスッと横に流れた。あの気まずさは忘れられません。
最初の頃の僕は、はみ出すたびに overflow-x: hidden で蓋をしていました。見た目は直る。でも中身は突き破ったまま、ただ隠れているだけ。蓋をした分だけ、後で別の場所が崩れる。
数年かけて分かったのは、レスポンシブは「あとから直す作業」ではなく「最初に決める設計」だということです。決める順番を間違えると、何時間直しても賽の河原になります。この記事は、僕が遠回りして身につけた「崩れない順番」を、実際に動くCSSと一緒に置いておくものです。
各UIパーツ(モーダル、画像ギャラリー、テーブル、無限スクロール)ごとの作り込みは別記事に分けたので、この記事はその土台、つまり「画面全体の幅をどう設計するか」のハブとして使ってください。
この記事の要点
- レスポンシブは「CSSを最後に足す作業」ではなく「最初に決める設計」。順番を間違えると永遠に直し続ける。
- 基本はモバイルファースト。小さい画面を初期値にして、広い画面だけ
@mediaで足す。上書きが減って読みやすくなる。 - 幅は固定px禁止。
min()/max()/clamp()で「最小・理想・最大」を一行で書くと、ブレークポイントの数が激減する。 - 部品の出し分けは画面幅ではなく「親要素の幅」で。
@container(コンテナクエリ)を使うと、同じカードがサイドバーでもメインでも正しく並ぶ。 - 画像は
srcset/sizes/<picture>でデバイスごとに出し分ける。スマホに巨大JPEGを配るのが一番もったいない。
なぜ「あとからレスポンシブ」は失敗するのか
PC幅で作り込んだ画面を、最後にスマホへ押し込む。これが一番ハマる罠です。
理由はシンプルで、PC前提のCSSには「固定幅」が埋まっているからです。width: 960px のカード、min-width: 1100px のテーブル、左右に効いた padding: 80px。どれもPCでは何の問題もない。だからレビューでも気づかれない。そしてスマホで開いた瞬間、画面(375px)より中身が広いので、ブラウザは正直に横スクロールを出します。
overflow-x: hidden で蓋をしても、突き破った部分が隠れるだけです。むしろ「中身がはみ出している」という事実が見えなくなる分、原因の特定が遅れます。僕はこれで一度、CTAボタンが画面外に消えていたのに数日気づかなかったことがあります。売上が落ちて、初めて気づきました。
だから順番を逆にします。小さい画面を「普通」にして、広い画面を「ご褒美」として足す。これがモバイルファーストの正体です。
モバイルファースト:小さい画面を初期値にする
モバイルファーストは「スマホ専用CSSを書く」ことではありません。初期状態(メディアクエリの外)をスマホ向けにして、@media (min-width: ...) で広い画面の指定だけを追加する書き方です。
なぜこれが効くのか。スマホは画面が狭いので、レイアウトは基本「1列に縦積み」で済みます。つまり初期状態のCSSが一番シンプルになる。そこから広い画面で「2列にする」「サイドバーを出す」と足していくほうが、上書き(打ち消し)が減ります。
逆に max-width で書き始めると、PC用の複雑なレイアウトを、狭い画面で一個ずつ無効化していく作業になります。打ち消しが増えるほど「どの幅で何が効いているのか」が読めなくなる。これが「直しても直しても崩れる」状態の正体です。
/* ❌ デスクトップファースト:狭い画面で一個ずつ打ち消す */
.layout {
display: grid;
grid-template-columns: 16rem 1fr; /* PC前提 */
}
@media (max-width: 768px) {
.layout {
grid-template-columns: 1fr; /* わざわざ打ち消す */
}
}
/* ✅ モバイルファースト:初期値は素直な縦積み、広い画面だけ足す */
.layout {
display: grid;
gap: 1.5rem; /* スマホ:自然に1列 */
}
@media (width >= 48rem) {
.layout {
grid-template-columns: 16rem 1fr; /* タブレット以上だけ2列 */
}
}
両者は見た目こそ同じ結果になりますが、半年後に読み返したときの分かりやすさがまるで違います。打ち消しが無いほうが、どこをいじれば何が変わるか一目で追えます。
ブレークポイントは「機種」で決めない
@media (width: 375px) のように、特定のiPhoneの幅で区切りたくなる気持ちは分かります。でもこれはやめたほうがいい。機種は毎年増えるし、折りたたみスマホやタブレットの縦横で、その「375px前提」は簡単に裏切られます。
僕が今やっているのは、機種ではなく「レイアウトが崩れる幅」で区切るやり方です。画面を狭くしていって、文字が窮屈になった瞬間、カードが潰れた瞬間、その幅にだけブレークポイントを置く。だから案件ごとに値は違っていい。
目安として、僕が出発点にしている区切りはこのくらいです。
| 区切り | 想定 | この幅で起きがちなこと |
|---|---|---|
| 〜480px | 小〜標準スマホ | カードを縦積み、ナビは折り返し |
| 480〜768px | 大型スマホ・縦タブレット | 2列にできる余地が出る |
| 768〜1024px | タブレット・小型ノート | サイドバーを出すか判断する分かれ目 |
| 1024px〜 | ノート・デスクトップ | 本文幅を広げすぎない上限が要る |
大事なのは、この表を「絶対」だと思わないことです。コンテンツが要求する幅で区切る。3つで足りるなら3つでいい。ブレークポイントは少ないほど保守が楽です。
なお、ブレークポイントの単位は rem を勧めます。px だとユーザーがブラウザの文字サイズを大きくしても区切り位置が変わりませんが、rem なら文字サイズに連動してレイアウトも切り替わるので、拡大時の崩れに強くなります。
固定pxをやめる:min / max / clamp
ブレークポイントを減らせる一番の武器が、流動的なサイズ指定です。min() / max() / clamp() を使うと、「この幅とこの幅の間で、なめらかに変化する」指定が一行で書けます。メディアクエリでカクカク段階的に変えていたものが、関数一個で済む。
それぞれの役割はこうです。
min(a, b)… aとbの小さいほうを採用。「最大でもここまで」の上限に使う。max(a, b)… aとbの大きいほうを採用。「最低でもここは確保」の下限に使う。clamp(最小, 理想, 最大)… 理想値を基本にしつつ、最小と最大ではみ出さないよう挟む。
具体例で見ます。一番よく使うのは、コンテンツ幅の指定です。
/* 狭い画面では左右に2remの余白を残し、
広い画面では72remで止めて読みやすさを守る */
.container {
width: min(100% - 2rem, 72rem);
margin-inline: auto;
}
min(100% - 2rem, 72rem) は、画面が狭いうちは 100% - 2rem(=左右に余白を残した全幅)を、広くなって72remを超えたらそこで止める、という意味です。これ一行で「スマホでは端まで使いすぎない」「PCでは間延びしない」の両方が片付きます。メディアクエリは不要です。
見出しのサイズも clamp() が効きます。
/* 最小2rem、画面に応じて5vw、ただし最大3.5rem */
h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
}
スマホでは2rem、画面が広がるにつれ大きくなり、3.5remで頭打ち。フォントサイズのためにブレークポイントを3つ書く、みたいな手間が消えます。MDNのclamp()に各引数の挙動が図つきで載っているので、迷ったら一度読むと腹落ちします。
ひとつ注意。clamp() の真ん中(理想値)に vw だけを置くと、ユーザーがズームしても文字が拡大されない問題が起きます。clamp(2rem, 1rem + 3vw, 3.5rem) のように rem を混ぜておくと、拡大にもちゃんと追従します。アクセシビリティ的にはこちらが安全です。この手の見落としを体系的に潰したい人はClaude Codeでアクセシビリティ対応を実装する実践ワークフローも合わせてどうぞ。
コンテナクエリ:画面幅ではなく「親の幅」で出し分ける
ここからが、レスポンシブ設計の考え方を一段変えてくれる話です。
長らくCSSの世界では「画面(ビューポート)の幅」しか条件にできませんでした。でも本当に知りたいのは、多くの場合「この部品が置かれた場所の幅」です。同じカードでも、広いメイン領域に置けば横長にしたいし、狭いサイドバーに置けば縦積みにしたい。画面幅だけでは、この出し分けができませんでした。
それを解決するのが**コンテナクエリ(@container)**です。「画面が何px」ではなく「親要素が何px」で中身を切り替えられます。
使い方は2ステップです。まず親に「ここを測りますよ」と宣言し、次に子で「親がこの幅なら」と条件を書きます。
/* 1. 親を「測定対象(コンテナ)」にする */
.card-list {
container-type: inline-size; /* 横幅を測る */
container-name: cards; /* 名前を付けておくと安全 */
}
/* 2. 子は「親の幅」で出し分ける(画面幅は見ていない) */
.card {
display: grid;
gap: 1rem;
}
@container cards (width >= 28rem) {
.card {
grid-template-columns: 8rem 1fr; /* 親が広いときだけ横並び */
align-items: center;
}
}
これの何がうれしいか。同じ .card を、メインに置いてもサイドバーに置いても、それぞれの幅に合わせて勝手に正しく並ぶんです。部品を「どこに置くか」を気にしなくてよくなる。コンポーネント指向の開発と相性が抜群です。
僕が初めてこれを使ったとき、それまで「ダッシュボード用カードCSS」「サイドバー用カードCSS」と二重に持っていたものが一本化できて、正直ちょっと感動しました。ブラウザ対応も主要ブラウザで出揃っているので、もう実務で使えます。詳しい構文はMDNの@containerが一次情報として確実です。
カード一覧そのものをグリッドで流動的に組む話はClaude CodeでCSS Gridを実務レベルにする方法、テーブルを画面幅に応じて行カード化する具体策はClaude Codeでテーブルコンポーネントを作るに分けてあります。このハブと往復して読むと、全体像がつながります。
画像の出し分け:srcset / sizes / picture
レスポンシブで一番もったいない失敗が、スマホに巨大なPC用画像を配ることです。1280px幅のヒーロー画像を、375pxの画面にそのまま渡したら、表示は縮むけど通信量は丸ごとかかる。CTAが出る前に読者が離脱します。回線の細い場所だと致命的です。
ここで使うのが srcset と sizes、そして <picture> です。役割を分けて覚えると混乱しません。
srcset… 「同じ絵の、サイズ違いの候補」をブラウザに渡す。sizes… 「この画像は実際どれくらいの幅で表示されるか」をブラウザに教える。これがないとブラウザが判断を誤る。<picture>… 形式(AVIF/WebP/JPEG)や、構図そのものを切り替えたいときに使う。
解像度違いを出し分けるだけなら img + srcset + sizes で足ります。
<img
src="/images/hero-1280.jpg"
srcset="
/images/hero-640.jpg 640w,
/images/hero-1024.jpg 1024w,
/images/hero-1280.jpg 1280w"
sizes="(width < 768px) 92vw, 40vw"
width="1280"
height="900"
alt="スマホとノートPCで同じページを開いて確認している様子"
loading="lazy"
decoding="async"
/>
sizes の (width < 768px) 92vw, 40vw は、「768px未満なら画面幅の92%、それ以外は40%で表示する」という申告です。ブラウザはこれを見て、srcset の候補から無駄のない一枚を自分で選びます。スマホには640w、PCには1280wが渡る、という具合です。
形式まで切り替えたいなら <picture> です。新しいAVIFに対応したブラウザにはAVIFを、古いブラウザにはJPEGを渡せます。
<picture>
<source
type="image/avif"
srcset="/images/hero-640.avif 640w, /images/hero-1280.avif 1280w"
sizes="(width < 768px) 92vw, 40vw"
/>
<img
src="/images/hero-1280.jpg"
width="1280" height="900"
alt="..."
loading="lazy" decoding="async"
/>
</picture>
ポイントは width と height を必ず書くこと。これを省くと、画像が読み込まれた瞬間にレイアウトがガクッとずれる(CLS)が起きます。属性で縦横比を伝えておけば、ブラウザが先に場所を確保してくれます。
画像の遅延読み込みや最適化を踏み込んでやるならClaude Codeで画像の遅延読み込みを安全に実装する、ライトボックス付きの一覧なら画像ギャラリーの難所は遅延読み込みとライトボックスに、つまずきポイントをまとめてあります。srcset / sizes の正式な仕様はMDNのレスポンシブ画像が確実です。
タッチ操作とビューポート:実機で初めてバレるやつ
PCのマウスで触っていると気づかない罠が、タッチ操作には潜んでいます。
ひとつ目はタップ領域の小ささ。リンクやボタンが小さいと、指で押し間違えます。目安として、押せる要素は最低でも縦横44px前後を確保します。文字リンクでも padding で当たり判定を広げておく。これだけで誤タップが目に見えて減ります。
ふたつ目は hover 頼みのUI。マウスの :hover で出てくるメニューは、指には hover が無いので開けません。タッチ環境でも操作できるよう、クリック(タップ)でも開く経路を必ず残します。
そして見落としの王様が <meta name="viewport"> です。これがheadに無いと、スマホのブラウザはページを「広い仮想画面」として描画し、せっかくのCSSが効いた結果を縮小表示してしまう。レスポンシブCSSが正しいのに実機だけズレる、という事故の大半はこれです。
<meta name="viewport" content="width=device-width, initial-scale=1" />
width=device-width で「実機の幅で描いてね」と伝えます。テンプレートやレイアウトコンポーネントに入っているか、まず確認してください。
コピペで動く:崩れないページの土台CSS
ここまでの考え方を一枚にまとめた、最小のレイアウトCSSです。ナビ・ヒーロー・カード一覧を含む簡単なHTMLに当てれば、スマホで横スクロールを出さず、コンテナクエリでカードが親幅に追従します。<head> に上のviewportタグを入れた <div class="page"> 構造に貼って、そのまま動きます。
/* === 土台:箱モデルと画像のはみ出し対策 === */
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.7;
color: #172033;
background: #f7f8fb;
}
/* 画像が親を突き破らない最低限の保険 */
img {
display: block;
max-width: 100%;
height: auto;
}
/* === コンテンツ幅:固定pxを使わずmin()で === */
.page {
width: min(100% - 2rem, 72rem); /* スマホは余白、PCは72remで停止 */
margin-inline: auto;
padding-block: clamp(1.5rem, 5vw, 4rem);
}
/* === ナビ:スマホは折り返し、タップ領域を確保 === */
.nav {
display: flex;
flex-wrap: wrap; /* 狭いと自動で折り返す */
gap: 0.5rem 1.25rem;
align-items: center;
}
.nav a {
min-height: 44px; /* 指で押せる最小サイズ */
display: inline-flex;
align-items: center;
padding-inline: 0.5rem;
color: inherit;
text-decoration: none;
}
/* === 見出し:clampでなめらかに(remを混ぜて拡大対応) === */
.hero h1 {
font-size: clamp(2rem, 1rem + 4vw, 3.5rem);
line-height: 1.15;
margin-block: 1rem 0.5rem;
}
.hero p {
max-width: 60ch; /* 行が長くなりすぎない */
font-size: clamp(1rem, 0.9rem + 0.6vw, 1.25rem);
}
/* === カード一覧:画面幅ではなく親幅で出し分ける === */
.card-list {
container-type: inline-size; /* この要素の幅を測る */
container-name: cards;
margin-block-start: 2.5rem;
display: grid;
gap: 1rem;
/* 親が狭くてもカードが画面を突き破らない保険つき自動列 */
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
}
.card {
background: #fff;
border: 1px solid #dbe3ef;
border-radius: 0.75rem;
padding: 1.25rem;
display: grid;
gap: 0.5rem;
}
/* 親(cards)が広いときだけ、注目カードを横長レイアウトに */
@container cards (width >= 40rem) {
.card.featured {
grid-column: span 2;
grid-template-columns: 10rem 1fr;
align-items: center;
}
}
このCSSの肝は3つです。width: min(100% - 2rem, 72rem) で固定幅を排除したこと。grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)) で、列数をブラウザに任せつつ「親が狭いときカードの最小幅が画面を突き破らない」保険(min(100%, 16rem))をかけたこと。そして @container でカードを親幅に追従させたこと。この3つが入っていれば、まず横スクロールは出ません。
仕上げに、できた画面が本当に崩れていないかは目視ではなく機械で確認するのが安全です。代表的な幅でスクリーンショットを撮り、横スクロールの有無を自動判定する流れはPlaywright E2Eがすぐ壊れる僕が、フレーキーを潰すまでにまとめてあります。
僕がやらかした失敗3つ
正直に書きます。順番を間違えて、何度も無駄な時間を溶かしました。
ひとつ目は、冒頭の overflow-x: hidden 蓋作戦。はみ出しの「原因」を消さずに「見た目」だけ消したので、別の場所で必ず再発しました。今は、はみ出しを見つけたら蓋をする前に「どの要素が親より広いのか」を先に特定します。だいたい固定px幅か、長いURLの折り返し漏れ(overflow-wrap: anywhere で解決)です。
ふたつ目は、ブレークポイントを機種で切ったこと。「iPhoneは375pxだから」と決め打ちしたら、新機種やタブレットで全部ズレました。今は機種を一切見ず、「文字が窮屈になった幅」「カードが潰れた幅」にだけ区切りを置いています。
みっつ目は、srcset だけ書いて sizes を忘れたこと。候補画像は用意したのに表示幅を教えなかったので、ブラウザが安全側に倒して一番大きい画像を選び続けました。スマホにPC用画像が降ってきて、軽くしたつもりが逆効果。srcset と sizes は二つで一組、と体に刻みました。
よくある質問
Q. モバイルファーストとデスクトップファースト、結局どっちがいい? A. 迷うならモバイルファーストです。初期値が縦積みで一番シンプルになり、広い画面の指定を「足す」だけで済むので、上書きが減って保守が楽になります。既存のPC前提コードを直す場合だけ、現実的に部分対応から入ることもあります。
Q. コンテナクエリとメディアクエリはどう使い分ける? A. ページ全体のレイアウト(サイドバーを出す/隠すなど)はメディアクエリ、再利用する部品(カードやウィジェット)の中身はコンテナクエリ、が基本の住み分けです。部品は「どこに置かれるか」が場面で変わるので、画面幅ではなく親幅で判断させたほうが壊れません。
Q. ブレークポイントは何個用意すればいい?
A. 少ないほどいいです。clamp() と auto-fit グリッドを使うと、多くの調整がメディアクエリ無しで吸収できるので、最終的に2〜3個に収まることも珍しくありません。「機種の数だけ用意する」のは逆効果です。
Q. clamp() でフォントを可変にするとアクセシビリティ的に問題ない?
A. 理想値に vw だけを置くと、ユーザーがズームしても文字が拡大されず問題になります。clamp(1rem, 0.9rem + 0.6vw, 1.5rem) のように rem を混ぜれば拡大に追従するので安全です。
Q. スマホで横スクロールが出る原因を素早く特定するには?
A. 開発者ツールで * { outline: 1px solid red; } を一時的に当てると、親をはみ出している要素がひと目で分かります。犯人はたいてい固定px幅、長いURL(overflow-wrap: anywhere で解決)、または横長のテーブルです。
実際に試した結果
冒頭の「直しても直しても横スクロールが出る」状態から抜け出せたのは、テクニックを増やしたからではありません。順番を変えただけです。
まず初期値をスマホ(縦積み)にして、広い画面だけ足す。幅は固定pxをやめて min() と clamp() に置き換える。部品の出し分けは @container に任せる。画像は srcset と sizes をセットで書く。この4つを徹底したら、375pxでも横スクロールが出ない画面が、メディアクエリ数個で組めるようになりました。CSSの行数はむしろ減りました。
今の僕は、新しい画面を作るとき最初に問うのは「PCでどう見せるか」ではなく「一番狭い画面で破綻しないか」です。狭いほうで成立していれば、広いほうは余白を足すだけで気持ちよく決まる。遠回りに見えて、これが一番崩れない近道でした。
各パーツの作り込み(モーダルのフォーカストラップ、無限スクロールのsentinelと位置復元など)は、この土台があってこそ活きます。設計の型を一段深めたい人はClaude Codeでデザインシステムを構築する実践ガイドへ、手を動かして学べる教材が欲しい人はClaude Code教材・プロンプト集も覗いてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。