iPhone の WebKit がブログを 100% 未満まで縮小していた — overflow-wrap と表のスクロールボックスで解決
iPhone でピンチインすると、このブログが 100% 未満まで縮小しました。WebKit は最小スケールをコンテンツ幅から決めるため、overflow-wrap と表のスクロールボックスで解決しました。

目次
はじめに
スマートフォンで自分の記事をピンチインしたところ、本来のサイズより小さく縮んでしまいました。違和感があったので調べることにしました。
トップページではそうなりません。ピンチインしてもページはその場に留まります。どちらも同じ <meta name="viewport"> で配信しているので、原因はタグではないだろうと考えました。
WebKit は縮小方向の最小スケールをドキュメントの実際のコンテンツ幅から決めますし、iOS 10 以降は minimum-scale を無視します。つまり下限を指定する手段はありません。本当の問題は ページがスマートフォンの画面からはみ出していること でした。
解決したのは CSS のルール 2 つです。インラインコードへの overflow-wrap: anywhere と、本文の幅より広い表を囲むスクロールボックスです。あわせて、全記事をスマートフォン幅で測定するチェックも追加しました。画面からはみ出したページは公開前に落ちます。

私の直感は間違っていた:viewport メタタグはズームアウトを止められない
ページのズームアウトを止められる viewport の値は存在しません。 スクリーンショットを見れば、メタタグに下限が足りないのだろうと考えたくなります。このサイトが全ページで配信しているのも、次の最小限の 1 行です。
<meta name="viewport" content="width=device-width, initial-scale=1" />この行に minimum-scale=1 を足すのが、私が最初に探した修正でした。しかし何も起きません。iOS 10 以降、WebKit は user-scalable、minimum-scale、maximum-scale をいずれも無視しており。リリース記事 にはこう書かれています。
そもそも直感の向き先も間違っていました。読めないほど広いコンテンツに読者が対処する手段がズームアウトです。 それを禁じるページは、別の問題を持ち込みかねません。
手がかりはトップページ — 同じメタタグ、違うコンテンツ
トップページはまったくズームアウトしませんし、記事と同じレイアウトから配信されています。タグは 1 つなのに挙動は 2 つなので、違いを生んでいるのはタグではありません。違うのは、それぞれのページが中に入れているものです。
これはページごとに 1 つの式で確かめられます。数値はすぐに食い違いました。
入力document.documentElement.scrollWidth出力390入力document.documentElement.scrollWidth出力591clientWidth はどちらも 390 でした。 390 のビューポートの中に 591 幅のドキュメントがあるということは、201px 分のコンテンツが画面の外にぶら下がっているということです。
同じ記事は、ウィンドウが 390 でも 500 でも 591 を返します。原因がビューポート側にないと分かったのはこの点でした。幅を決めていたのは記事の中にある何か であり、ビューポートを無視する数値はビューポートでは直せません。
ズームの下限はドキュメント自身の幅
WebKit はページを device-width でレイアウトし、コンテンツがそこからはみ出していることを検知して、すべてが画面に収まるスケールを選びます。WebKit 自身の説明によれば、これは読者がピンチインで全体が見えるところまで縮小するのと概念的には同じです。
つまり 最小スケールはページが自分自身について計算する商 です。画面幅をコンテンツ幅で割った値になります。
ここまでの測定はすべて iPhone の Chrome で行い、スクリーンショットもそこから取ったものです。この挙動はブラウザ側ではなくエンジン側のもので、iOS のブラウザはいずれも WKWebView で描画するため、Safari もほかのブラウザも同じ挙動を受け継ぎます。
スクリーンショットの数値はすべてこの商から導けます。430pt の画面に対して 591px のドキュメントなら下限は 0.73、このサイトで最悪のページはドキュメントが 1028px あり、下限は 0.38 でした。調整が必要だったのはズームではなく、商の分母のほうでした。
全記事を調べて分かったこと
1 ページ分のはみ出しはそのページだけの修正で終わります。次の問いは、何ページで起きているのかでした。本番のサイトマップにある 356 ページすべてを 390 × 844 で読み込み、scrollWidth を clientWidth と比較しました。
入力node --input-type=module -e "…" # playwright over every route in the sitemap出力routes measured: 356 overflowing: 63原因は最初のページから想像したものとは違いました。ビューポートを越えた要素をタグごとに集計するとこうなります。
| 要素 | その要素が現れたはみ出しページ数 |
|---|---|
code | 63 |
table とそのセル | 52 |
a | 8 |
strong | 6 |
インラインコードは 63 件すべてに現れ、表は 52 件でした。 私が見ていた記事はたまたま表のケースで、サイトで最悪のページには表が 1 つもありません。段落の中でインライン code として組まれた Windows のレジストリパスが、それだけで 1009px に達していました。
入力node scripts/check-overflow.ts出力 right=1009 div.page > main > article > div.prose > p > code text: "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnloc"Expressive Code の行 span はほぼすべての記事でビューポートを越えますが、上の表には一度も出てきません。なぜ出てこないのかが、2 つの修正の背後にある仕組みそのものです。
ボックスに収まらないものは、隠れてくれません。 既定の overflow: visible は、ボックスより広い内容もそのまま描き、その分を「ページを横にどれだけスクロールできるか」に数える、という意味です。すぐ外のボックスもその幅を受け取り、さらに外側も同じで、最後は文書全体まで届きます。だから段落の中の 1009px の文字列が、文書そのものを 1009px にしてしまいます。
その幅を受け取らないのが overflow-x: auto です。 これを付けたボックスはスクロールコンテナになり、はみ出した分はボックスの縁で切り取られて、内側でスクロールします。外側のボックスに見えるのはボックス自身の幅だけで、余分な幅は渡りません。文字の幅は変わらず、文書がその幅を数えなくなるだけです。
だからこそ この行 span はページを広げられません。それらが入っている <pre> はすでにスクロールするので、はみ出しはその縁で止まります。ビューポートを越えた要素をすべて報告するチェックなら、どの記事でも永久にこれを挙げ続けることになります。
コードを折り返す:break-word ではなく overflow-wrap
折り返せる位置のない単語は、ブラウザも折り返せません。だからインラインコードの中の長いトークンは、文書そのものをそのトークンと同じ幅まで広げます。直すのは、このサイトにもとからあったセレクタに overflow-wrap: anywhere を 1 行足すだけです。
:not(pre) > code { background: var(--bg-2); border: 1px solid var(--border); border-radius: 4px; padding: 0.1em 0.35em; overflow-wrap: anywhere;}break-word ではなく anywhere です。min-content 幅を縮めるのは anywhere だけです。
そのため、効くのは段落のケースだけではありません。インラインコードを含む表のセルはここではほとんどがそれに当たり、それらが軒並み狭くなるので、表の問題の一部もこれで一緒に片付きます。
表を箱に入れる:2 つの選択肢と DevelopersIO の実装
はみ出すほど広い表に対する修正は 2 つあります。私は、表を毎日のように載せているサイトがどうしているかを知りたいと思いました。DevelopersIO は表の多い日本語の技術記事を出しているので、その 1 ページを同じ方法で測りました。
| dev.classmethod.jp | このブログ(修正前) | |
|---|---|---|
| viewport メタタグ | width=device-width, initial-scale=1 | 同一 |
390 での documentElement.scrollWidth | 390 | 591 から 1028 |
表の計算後の display | block | table |
表の計算後の overflow-x | auto | visible |
| 表を囲むラッパー要素 | なし | なし |
インラインコードの計算後の overflow-wrap | break-word | 未指定 |
採用されているのは CSS だけの修正 で、ラッパー要素はどこにもありません。表をブロックボックスにしてスクロールさせています。これは機能しますし、ビルド手順も要らず、2 行で済みます。
display: block と、幅の狭い表が払う代償
代償を払うのは、もともと広すぎなかった表のほうです。 内容が本文の幅より狭い表は、そこいっぱいに広がるのをやめて自分の文字の幅まで縮み、残りは空白になります。このサイトのレイアウトで測ると、712px のカラムに対して 711px だったものが 139px になりました。
display: block では自分の文字の幅しか残らず、もともと本文より広い表は見え方が変わりません。DevelopersIO はこの代償をそのまま受け入れています。 記事 8 本を測ったところ、表 7 つのうち 4 つは本文の幅より狭く、左に寄って縮んだまま表示されます。内容がカラムより広い表なら両者の差は見えません。そうした行は 656px のカラムの中で 655px を返します。
ラッパー要素と、こちらを選んだ理由
もう 1 つの選択肢は、表そのものには手を付けず、スクロールする要素で囲むものです。すべての表が display: table のままなので、描画は何も変わりません。 スクロールバーが付くのは広すぎる表だけです。私が選んだのはこちらで、決め手になったのは、このリポジトリにすでに書かれていて使われていなかったルールを見つけたことでした。
/* Wide content must scroll in its own box, never the page body. */.table-scroll { overflow-x: auto;}このクラスを適用している箇所はリポジトリのどこにもありませんでした。意図だけが書き残されて、それを動かす実装がなかったわけです。そこで、ツリーの構築時に各 Markdown の表を囲む rehype プラグインを書きました。
if (child.type === "element" && child.tagName === "table") { children[i] = { type: "element", tagName: "div", properties: { className: ["table-scroll"], tabIndex: 0 }, children: [child], };}markdown.rehypePlugins は非推奨で、実行のたびにそう表示されます。
[astro] `markdown.remarkPlugins`, `markdown.rehypePlugins`, and`markdown.remarkRehype` are deprecated. Pass them to `unified({...})` from`@astrojs/markdown-remark` directly instead.import { unified } from "@astrojs/markdown-remark";
markdown: { processor: unified({ rehypePlugins: [rehypeTableScroll] }),},unified() は Markdown 処理系そのもの、Markdown を解析して HTML を出すパイプラインです。Astro のエクスポートは本来使われるはずのものをそのまま組み立て、プラグインのリストを受け取ります。remark プラグインは Markdown のツリーに、rehype プラグインは変換後の HTML のツリーに作用します。rehypeTableScroll が包む <table> 要素は、HTML のツリーができて初めて存在します。
渡したオプションはそのまま使われ、渡さなかったものは既定値で埋まるので、rehype プラグインを 1 つ登録しても他が切れることはありません。GFM も有効なままです。 GFM は Markdown に表の記法を足す拡張で、素の Markdown に表はありません。これがなければ、包む表そのものが存在しません。
このスニペットの tabIndex: 0 があると、読者は Tab キーでスクロールボックスに移動し、矢印キーで広い表の右側の列まで送れます。
390px で全記事を測るゲート
この調査はそのまま pnpm check:overflow というスクリプトになりました。全ロケールの全記事とユーティリティページを 390 × 844 で読み込み、scrollWidth が clientWidth を超えたページを失敗させます。
入力pnpm check:overflow出力Nothing overflows. 211 route(s) measured at 390px.まだ修正していないサイトに対して、同じチェックを実行しました。
入力FIT_BASE_URL=https://oharu121.com pnpm check:overflow出力63 route(s) wider than the viewport, of 191 measured.その要素より外側のボックスがすでにスクロールか切り取りをしている場合、つまり overflow-x の計算値が auto、scroll、hidden のいずれかである場合、その要素は数えません。これにより Expressive Code の span は報告から外れ、表と段落は残ります。
このチェックは フィードの後続ページを計算ではなく実際に叩いて探します。これにより POSTS_PER_PAGE から算出したのでは見落としていた 13 ページが見つかり、対象は 198 から 211 になりました。
このチェックは pnpm check には含めていません。サーバーの起動が必要だからで、このリポジトリの図のチェックが除外されているのと同じ理由です。代わりに公開ステップが、すでに起動している開発サーバーに対して実行します。
まとめ
iPhone で 100% 未満までピンチインできるページは、viewport の値が足りないのではなく、自分のコンテンツ幅をそのままブラウザに伝えているだけです。WebKit はスケールの下限を画面幅とコンテンツ幅の比で決め、iOS 10 以降は minimum-scale を無視するので、動かせるのはレイアウトだけです。
ここでのすべてのケースはルール 2 つで片付きました。1 つはインラインコードへの overflow-wrap: anywhere です。break-word ではなく anywhere なら min-content も縮み、コードを含む表のセルも狭くなります。
もう 1 つは広い表を囲むスクロールボックスで、表そのものへの display: block ではなくラッパーとして入れます。そうすればもともと収まっていた表は本文の幅を埋めたままです。ラッパーには tabindex="0" を付けます。読者は Tab キーでそこに移動し、矢印キーで表の右側の列まで送れます。
残りは測定です。スマートフォン幅で scrollWidth を clientWidth と比べます。きっかけになった 1 ページではなく全記事に対して行い、スクロールコンテナの内側の要素は、そのはみ出しがドキュメントまで届かないので除外します。
参考リンク
- WebKit: New interaction behaviors in iOS 10:
user-scalable、minimum-scale、maximum-scaleが無視されるようになった件 - MDN: viewport メタ要素と、各ディレクティブの働き
- MDN:
overflow-wrap、anywhereとbreak-wordの min-content サイズでの違い - Understanding WCAG 1.4.10 Reflow、320 × 256 CSS ピクセルの要件
- Apple: Configuring the viewport:この挙動の土台になる
shrink-to-fitについて

