WebKit zoomed this blog out below 100% on an iPhone — overflow-wrap and a table scroll box fixed it

Pinch-zooming out on an iPhone shrank this blog below 100%. WebKit takes its zoom floor from content width, so the fix was overflow-wrap and a table scroll box.

The WebKit logo, three stacked rounded squares in blue, yellow and orange with a white-outlined compass needle on the blue one, beside the black WebKit wordmark on a white card
On this page

Introduction

Pinching to zoom out on one of my own articles on my phone shrank it below its normal size, which felt wrong enough to look into.

The home page would not do it: pinch to zoom out there and the page stays where it is. Both are served with the same <meta name="viewport">, so I doubted the tag was the difference at all.

WebKit takes its minimum zoom-out scale from the document’s real content width, and it has ignored minimum-scale since iOS 10, so there was nothing to cap. The real problem is that the page overflows the phone.

Two CSS rules fixed it: overflow-wrap: anywhere on inline code, and a scroll box around any table too wide for the body text. I also added a check that measures every article at a phone’s width, so a page that outgrows the screen fails before it ships.

An article page in Chrome on an iPhone at its minimum zoom, the address bar reading oharu121.com above a content column that fills about three quarters of the screen width, with a dark empty band down the right side

The screen is 430 points wide and the document is 591, so the smallest scale the browser allows is 430/591, and the remaining 27% is empty page.

My instinct was wrong: the viewport meta tag cannot cap zoom-out

There is no viewport value that stops a page zooming out. I read the screenshot as a meta tag missing a floor, and this site serves the minimal one on every page:

src/layouts/BaseLayout.astro
<meta name="viewport" content="width=device-width, initial-scale=1" />

Adding minimum-scale=1 to that line is the fix I went looking for, but it does nothing. Since iOS 10, WebKit has ignored user-scalable, minimum-scale and maximum-scale outright, and the release post is blunt about it:

The instinct was also aimed at the wrong thing: zooming out is how a reader copes with content too wide to read. A page that forbids it could introduce more problems.

The home page was the clue — same meta tag, different content

The home page would not zoom out at all, and it is served from the same layout as every article. One tag, two behaviours, so the tag was not the variable. What differed was what the two pages put inside it.

That is checkable in one expression per page, and the numbers come back apart immediately:

the home page, at a 390px viewport
IN
document.documentElement.scrollWidth
OUT
390
an article, at the same viewport
IN
document.documentElement.scrollWidth
OUT
591

clientWidth was 390 on both. A document 591 wide inside a 390 viewport is 201 pixels of content hanging off the side of the screen.

The same article reports 591 whether the window is 390 or 500 wide, which is the part that pointed away from the screen entirely. Its width was being set by something inside the article, and a number that ignores the viewport is not a number the viewport can fix.

The zoom floor = document’s own width

WebKit lays a page out at device-width, notices that the content overflows that, and picks the scale that fits all of it on screen. Its own description of the behaviour is that this is conceptually the same as the reader pinch-zooming out far enough to see everything.

So the minimum scale is a quotient the page computes about itself: screen width over content width.

All of this was measured in Chrome on an iPhone, which is where the screenshot came from. The behaviour belongs to the engine rather than the browser: every iOS browser renders in WKWebView, so Safari and the rest inherit it.

The zoom floor is screen width over content widthTwo panels. On the left the page is laid out at scale 1: the screen is 390 pixels wide and the document is 1028, so 638 pixels of it sit off the right edge. On the right the browser has picked the scale that fits the whole document on the screen, so the body text now occupies about a third of the width and the rest of the screen is empty page. Between them, the quotient 390 divided by 1028 equals 0.38.Laid out at scale 1At the minimum scalescreen 390pxdocument 1028px638pxoff screenscreen 390pxdocument 1028pxbody textempty page390 / 1028 = 0.38Minimum scale = screen width / content width
The layout viewport stays at the device width. The document is wider, so the scale that fits it is what the reader is allowed to reach.

Every number in the screenshot falls out of that quotient. A 591-pixel document on a 430-point screen floors at 0.73, and the worst page on this site floored at 0.38, because its document was 1028 pixels wide. Nothing about zoom needed adjusting; the quotient needed a smaller denominator.

What a sweep of the whole site found

One page’s overflow is a per-page fix, so the next question was how many pages had it. All 356 pages in the production sitemap, loaded at 390 by 844, with scrollWidth compared against clientWidth:

Terminal window
IN
node --input-type=module -e "…" # playwright over every route in the sitemap
OUT
routes measured: 356 overflowing: 63

The offenders were not what the first page suggested. Grouped by the tag of every element whose right edge crossed the viewport:

ElementOverflowing pages it appeared on
code63
table and its cells52
a8
strong6

Inline code was on every one of them, and tables on 52. The article I had been looking at happened to be a table case, and the worst page on the site has no table at all: a Windows registry path in a paragraph, set as inline code, reached 1009 pixels on its own.

IN
node scripts/check-overflow.ts
OUT
right=1009 div.page > main > article > div.prose > p > code
text: "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnloc"

Expressive Code’s line spans cross the viewport on nearly every article, and they appear nowhere in that table. Why they are absent is the whole mechanism behind both fixes.

Nothing hides what a box cannot fit. The default, overflow: visible, means content wider than its box is still drawn, and still counts towards how far the page can scroll sideways. Every box outside it takes that width too, up to the document. So a 1009-pixel string in a paragraph makes the whole document 1009 pixels wide.

overflow-x: auto is what refuses that width. A box with it set becomes a scroll container: anything wider is clipped at its edge and scrolls inside it instead. The boxes outside now see only that box’s own width, so the extra never reaches them. The text is as wide as it ever was; the document stops counting it.

That is why those line spans cannot widen the page. The <pre> they sit in already scrolls, so their overflow dies at its edge, and a check that flags any element crossing the viewport would report them on every article forever.

Overflow inside a scroll container never reaches the documentTwo stacks of nested boxes. On the left, a span overhangs its pre element, which has overflow-x auto, so the overflow stops at that box and the document keeps its width: not a defect. On the right, inline code overhangs its paragraph inside div.prose, where nothing scrolls or clips, so the overhang runs out of the page and widens the document: a defect.Clipped at the scroll boxdiv.expressive-codepre (overflow-x: auto)spanReaches the documentdiv.prosepcodenot a defecta defectA leaf only changes the document width when no ancestor scrolls or clips
Overflow inside a scroll container is clipped at that container. A leaf changes the document’s width only when nothing above it scrolls or clips.

Wrapping the code: overflow-wrap, not break-word

A browser will not break a word that has nowhere to break, so a long token in inline code makes the document as wide as the token itself. The fix is one property, overflow-wrap: anywhere, on a selector this site already had:

src/styles/global.css
:not(pre) > code {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.1em 0.35em;
overflow-wrap: anywhere;
}

anywhere rather than break-word. Only anywhere shrinks the element’s min-content width.

So it does more than wrap the paragraph case. It narrows every table cell that holds inline code, which here is most of them, and part of the table problem goes with it.

Boxing the table: two candidates, and what DevelopersIO ships

A table wide enough to overflow has two available fixes, and I wanted to know what a site that publishes tables every day does about it. DevelopersIO runs Japanese technical articles full of them, so one of its pages got measured the same way:

dev.classmethod.jpThis blog, before the fix
Viewport metawidth=device-width, initial-scale=1identical
documentElement.scrollWidth at 390390591 to 1028
Computed display on a tableblocktable
Computed overflow-x on a tableautovisible
Wrapper element around itnonenone
Computed overflow-wrap on inline codebreak-wordunset

They ship the CSS-only fix, with no wrapper element anywhere: the table is made a block box and told to scroll. It works, it needs no build step, and it is two lines.

display: block, and what it costs a narrow table

The cost lands on the tables that were never too wide. A table whose content is narrower than the body text stops spanning it and shrinks to the width of its own text, leaving the rest of the line empty. Measured on this site’s own layout: 711 pixels of a 712-pixel text width before, 139 after.

What display: block costs a narrow tableTwo rows, each showing the same table at the same body-text width. In the upper row the table is set to display: table and fills that width, 711 pixels of 712. In the lower row it is set to display: block and occupies only 139 pixels at the left, with the rest of the width empty.body text, 712px widedisplay: tableStatusCountspans the full width · 711pxdisplay: blockleft emptyStatusCountshrinks to its own text · 139px
The same table at the same body-text width. display: block keeps only the width of its own text; a table already wider than the body text looks identical either way.

DevelopersIO simply accepts that cost. Measured across eight of their articles, four of the seven tables are narrower than the body text and render shrunk against its left edge. Where a table’s content is wider, the two approaches are indistinguishable: those rows come back 655 pixels inside a 656-pixel text width.

The wrapper element, and why it won here

The other candidate wraps the table in an element that scrolls, leaving the table itself alone. Every table keeps display: table, so nothing renders differently and only the too-wide ones gain a scrollbar. That is what I chose, and what settled it was finding the rule already written in this repo, unused:

src/styles/global.css
/* Wide content must scroll in its own box, never the page body. */
.table-scroll {
overflow-x: auto;
}

Nothing in the repo applied that class. The intent had been recorded and the wiring never built, so the work was a rehype plugin that wraps each markdown table as the tree is built:

src/lib/rehype-table-scroll.ts
if (child.type === "element" && child.tagName === "table") {
children[i] = {
type: "element",
tagName: "div",
properties: { className: ["table-scroll"], tabIndex: 0 },
children: [child],
};
}

markdown.rehypePlugins is deprecated, and says so on every run:

[astro] `markdown.remarkPlugins`, `markdown.rehypePlugins`, and
`markdown.remarkRehype` are deprecated. Pass them to `unified({...})` from
`@astrojs/markdown-remark` directly instead.
astro.config.mjs
import { unified } from "@astrojs/markdown-remark";
markdown: {
processor: unified({ rehypePlugins: [rehypeTableScroll] }),
},

unified() is the Markdown processor itself, the pipeline that parses Markdown and emits HTML. Astro’s export builds the same one it would have used anyway and takes your plugin lists as arguments: remark plugins run on the Markdown tree, rehype plugins on the HTML tree after it. rehypeTableScroll wraps a <table> element, which only exists once the HTML tree does.

It uses the options you pass and fills in the rest from their defaults, so registering one rehype plugin switches nothing else off. GFM stays on, and GFM is the Markdown extension that adds table syntax, which plain Markdown has none of. Without it there would be no tables for the plugin to wrap.

The tabIndex: 0 in that snippet lets a reader tab to the scroll box and walk a wide table’s far columns with the arrow keys.

The gate: every article measured at 390px

The sweep became a script, pnpm check:overflow, which loads every article in every locale plus the utility pages at 390 by 844 and fails any page whose scrollWidth exceeds its clientWidth:

Terminal window
IN
pnpm check:overflow
OUT
Nothing overflows. 211 route(s) measured at 390px.

I ran the same check against the site that had not been fixed yet:

Terminal window
IN
FIT_BASE_URL=https://oharu121.com pnpm check:overflow
OUT
63 route(s) wider than the viewport, of 191 measured.

If any box above an element already scrolls or clips (a computed overflow-x of auto, scroll or hidden), that element is skipped. That keeps Expressive Code’s spans out of the report and leaves the table and the paragraph in.

The check also probes the feed’s later pages rather than computing how many there are. That found 13 pages a count derived from POSTS_PER_PAGE would have missed, taking the run from 198 to 211.

It is not part of pnpm check, because it needs a server running, which is the same reason this repo’s figure check is excluded from it. The publish step runs it instead, on the dev server it has already started.

Summary

A page that can be pinched below 100% on an iPhone is reporting its own width, not a missing viewport value. WebKit floors the scale at screen over content and has ignored minimum-scale since iOS 10, so the only lever is the layout.

Two rules covered every case here. overflow-wrap: anywhere on inline code, where anywhere rather than break-word also shrinks min-content and narrows the table cells that hold code.

And a scroll box around a wide table, as a wrapper rather than display: block on the table itself, so the tables that already fitted keep filling their width. The wrapper takes tabindex="0", so a reader can tab to it and walk the table’s far columns with the arrow keys.

The rest is measurement. scrollWidth against clientWidth at a phone’s width, on every article rather than the one that prompted the question, with elements inside a scroll container excluded because their overflow never reaches the document.

References

Share this article