目次
1.はじめに
開発中、以下のような「特定端末だけ表示が崩れる」トラブルに遭遇することがあります。
- 同じ画面が、あるiPadでは正常なのに別のiPadでは崩れる
- 手元のPCでは開発者ツールを駆使しても再現できない
この手の問題は端末の差ではなくブラウザのバージョン差が原因であることがほとんどです。
この記事では、業務の都合上実機が手元にない状態で
「特定のOSバージョンのiPadでレイアウトが崩れる」
という事象を机上調査したときの原因の切り分け方と最終的な真犯人をまとめます。
筆者について
- Laravelのバックエンド開発を4年
- フロントエンド(Vue.js/SCSS/HTML)は必要に応じて触る程度
普段CSSの深いところに踏み込まないぶん、今回のような描画レベルの崩れにはかなり手こずりました。
「フロント専門ではないけれど、たまたま調査を任された」
そんな立場の方にとって、本記事が少しでも役に立てば幸いです。
2.発生した事象
あるフォーム画面のカスタムチェックボックスが、特定のiPadだけ表示が崩れていました。
- iPad第7世代(iPadOS18.7.9)→正常
- iPad第5世代(iPadOS16.7.11)→一部レイアウトが崩れる
崩れは操作の結果ではなく、初期表示の時点から発生しています。
さらに厄介なことに、実機が手元になく試せない為、
崩れたスクリーンショットを見ながら机上でCSSを追う調査になりました。
3.最初の壁:手元で再現できない
業務上開発端末として使用できるのはPC(Chrome/Edge)のみで実機を気軽に利用できる環境でなかったため。
なので机上検証、検証環境にデプロイ、実機を借りて動作確認、という流れになったのが非常につらかった
4.原因の切り分け
ここからは怪しい候補をひとつずつ潰していった過程です。
仮説1:CSSネスト
ネイティブCSSネストはSafali16/18で明確に挙動が変わる
- Safali 16.5~17.1:厳密(strict)文法
→要素セレクタを&なしでネストするとルールが解釈されない - Safali 17.2以降:緩和(relaxed)文法
→&なしでも通る
.card {
padding: 1rem;
h3 { /* Safari 16.5〜17.1 では、このネストが丸ごと落ちる */
font-size: 3rem;
}
}
h3 { ... } のような「& なしの要素ネスト」は Safari 16 でルールごと捨てられ、Safari 18 では通ります。落ちたルールがレイアウトを支えていれば初期表示から崩れるので、症状の方向性は一致します。
判定
今回のプロジェクトはAngular(HTML,TypeScript,CSS)でビルドする構成でした。
Angular CLIのビルドではSCSSのネストはビルド時に展開されてCSSに出力されます。
つまり、ブラウザに届くのは以下のような展開済みのCSSであり、ネイティブCSSネストはそもそも使われていませんでした。
.card {
padding: 1rem;
}
.card h3 {
font-size: 3rem;
}
つまり今回の表示崩れはCSSネストの書き方によるものではない。
→仮説1はシロ
仮説2: :has()
崩れている要素に対し、下記のようなセレクタがありました。
.customCheckBox:has(input[type=checkbox]:focus-visible) .check-img {
border: 21x solid #000;
padding: 12px;
}
「:has()なんて新しめの機能(筆者が見覚えなかっただけ)、Safali 16 で対応してないのでは?」と一瞬疑った
判定
MDNでブラウザ対応を調べたところ、Safali 15.4で完全サポート済み。
つまりSafalli 16 とSafali 18 の間で挙動は変わりません。
→仮説2もシロ
仮説3:position: absolute + top/left未指定 ← 真犯人
対象要素の状況
セレクタ系の仮説が尽きたので、崩れている要素そのもののpositionに注目しました。
DOM構造はカスタムチェックボックスの典型でした。
<p>
<label>
<span> ← 直接の親(position 指定なし = static、かつ inline)
<span> ← position: absolute(ずれる要素。top/left 未指定)
<input> ← position: relative(ずれない要素。 top/left 指定)
見た目用の<span>を本物の<input>に重ねる作りです。
崩れている<span>の状況を棚卸すると、
-
position: absoluteがついている -
top/leftが未指定 (=auto)
この2番目が決定打でした。
5.原因:top/leftがautoの絶対配置は「静的位置」に置かれる
CSS仕様では、絶対配置要素のtop(とbottom)が両方autoのとき、その使用値は静的位置(static position)になります。(left/rightも同じです)
静的位置とは
「もしこの要素がstaticのまま普通にフローに並んでいたら、本来どこにいたか」
という位置です。
つまり、top/leftを書かない絶対配置は、包含ブロックの角に飛ぶのではなく、元居た場所付近に留まろうとします。
そして肝心なのは、この静的位置の計算は、CSS仕様の中でも細部がブラウザ実装にゆだねられている曖昧な領域だという点です。
特に今回のように「直接の親がインライン要素」の文脈では、
- 行ボックスの高さ
- ベースライン
- 兄弟要素のサイズ
などが絡み、計算結果がブラウザ世代で揺れます。
結果として、
- Safali 18: 静的位置の計算が、たまたま意図した位置に
<span>を置いた - Safali 16: 同じCSSでも計算結果が違い、
<span>が別の場所に落ちた→初期表示から崩れる
→仮説3が黒確定。
6.ハマりポイント:「上にrelativeがあるから大丈夫」は誤解
調査中、「もっと上の階層に position: relative の要素があるので基準はあるはず」と考えて一度安心しかけました。ここが最大の落とし穴です。
絶対配置には二段構えの仕組みがある
- 包含ブロック(上の relative 祖先):top: 20px のように具体的なオフセット値を書いたときの「座標原点」。width: 50% のような割合の基準にもなる
- 静的位置:top/left が auto のとき、要素はこっちに置かれる。包含ブロックの角ではなく、自分が本来フロー内でいたはずの場所
今回はtop/leftがauto。つまり relative祖先 は「もしオフセットを書いたら基準になりますよ」という待機状態にすぎず、実際には何も効いていませんでした。位置を決めていたのは、依然として「直接の親(インライン)の中での静的位置」です。
7.解決方法
原因の裏返しがそのまま対策です。
あいまいだった「基準」と「オフセット」の両方を明記してあげます。
<p>
<label>
<span> ← 直接の親(position: relative)
<span> ← position: absolutet; top: 0; left: 0;
<input> ← position: relative
これで2つ同時に解決します。
- top/left が auto でなくなるので、バージョンで割れる静的位置の計算そのものを使わなくなる
- 包含ブロックが「遠い祖先」から「直接の親」に移り、 が意図どおり隣の にぴったり重なる
結果、Safari 16 でも 18 でも同じ表示になります。
8.まとめ
- iPadOS のバージョン = Safari のバージョン。端末差に見える崩れは、たいていエンジンのバージョン差
- 調査の合言葉は「16 と 18 で挙動が変わるか?」。両方で同じ挙動の機能(今回の :has() など)は自動的に無罪
- 「解釈済みの表示」ではなく「配信された生CSS」を見る(Styles パネルではなく Network の Response / view-source:)
- position: absolute を書いたら、「基準(近い祖先の relative)」と「オフセット(top/left)」を必ずセットで用意する
- top/left が auto の絶対配置は「静的位置」に置かれる。これは実装差の温床。上に relative があっても、auto の間は使われない
absoluteは「なんとなく重ねる」で動いてしまうぶん、top/left を省略しがちです。
でもその省略が、こういうクロスバージョン崩れの入り口でした。
absolute には基準とオフセットを明示する。
これを癖にするだけで、この種のバグはほぼ根絶できます。
9.反省
筆者はフロントエンドをあまり触っておらず、さらに今回は Angular を触るのが初めて だったため、
変に深読みしすぎて、基本的な原因ながら 5 時間ほどハマってしまいました。
冷静に考えれば、
- 特定要素の位置がおかしい
- その要素に当たっているスタイルを確認
- 位置がずれそうなスタイル(今回なら position)を確認
- その仕様を調べる
と進めれば、1 時間足らずで解決できたと思います。
CSS を本格的に勉強したのは約 8 年前だったので、
今回を機に復習しようと思いました。