# 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.

- Source: https://oharu121.com/blog/webkit-ios-pinch-zoom-floor-overflow-wrap-table-scroll/
- Published: 2026-09-22T21:12:42+09:00
- Tags: CSS, WebKit, Accessibility

---
## 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.

*Image: 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:

```astro title="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](https://webkit.org/blog/7367/new-interaction-behaviors-in-ios-10/) is blunt about it:

> **HEADS UP**
>
> **From WebKit's iOS 10 release post.** Safari on iOS allows the user to pinch zoom on every page, so developers should make sure content works well when zoomed.

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:

```js title="the home page, at a 390px viewport"
document.documentElement.scrollWidth
# 390
```

```js title="an article, at the same viewport"
document.documentElement.scrollWidth
# 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.

*Figure — ZoomFloor: 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`:

```bash
node --input-type=module -e "…"   # playwright over every route in the sitemap
# 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:

| Element | Overflowing pages it appeared on |
| --- | --- |
| `code` | 63 |
| `table` and its cells | 52 |
| `a` | 8 |
| `strong` | 6 |

**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.

```text
node scripts/check-overflow.ts
#   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.

*Figure — OverflowReach: 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:

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

> **HEADS UP**
>
> **`:not(pre) > code` means inline code only.** `<code>` appears twice on this site: inside a `<pre>` for a whole code block, and on its own for a word in a sentence. The selector matches the second and never the first, so the rule reaches prose without touching Expressive Code's blocks.

`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.jp | This blog, before the fix |
| --- | --- | --- |
| Viewport meta | `width=device-width, initial-scale=1` | identical |
| `documentElement.scrollWidth` at 390 | 390 | 591 to 1028 |
| Computed `display` on a table | `block` | `table` |
| Computed `overflow-x` on a table | `auto` | `visible` |
| Wrapper element around it | none | none |
| Computed `overflow-wrap` on inline code | `break-word` | unset |

**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.

*Figure — NarrowTable: 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:

```css title="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:

```ts title="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:

```text
[astro] `markdown.remarkPlugins`, `markdown.rehypePlugins`, and
`markdown.remarkRehype` are deprecated. Pass them to `unified({...})` from
`@astrojs/markdown-remark` directly instead.
```

```js title="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.

> **NICE**
>
> **What shipped.** Every table that already fitted looks exactly as it did. The one too wide for the text now scrolls inside its own box, and the page no longer scrolls with it. On a laptop nothing changed at all.

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`:

```bash
pnpm check:overflow
# Nothing overflows. 211 route(s) measured at 390px.
```

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

```bash
FIT_BASE_URL=https://oharu121.com pnpm check:overflow
# 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

- [WebKit: New interaction behaviors in iOS 10, including that `user-scalable`, `minimum-scale` and `maximum-scale` are now ignored](https://webkit.org/blog/7367/new-interaction-behaviors-in-ios-10/)
- [MDN: the viewport meta element, and what each directive does](https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Viewport_meta_element)
- [MDN: `overflow-wrap`, including how `anywhere` differs from `break-word` on min-content sizing](https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-wrap)
- [Understanding WCAG 1.4.10 Reflow, the 320 by 256 CSS pixel requirement](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html)
- [Apple: Configuring the viewport, for the `shrink-to-fit` behaviour this rests on](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/UsingtheViewport/UsingtheViewport.html)
