A few days ago I was reading my own blog on my phone and tapped the "Share" button in the post header. The right side of the modal was cut off: the URL was truncated, the "Copy link" button sat noticeably too far right, and the modal was not centered on the screen at all - it was "overflowing to the right".
Oddly enough, the same post behaved perfectly when I dragged my desktop browser window down to 375px wide - the modal dutifully shrank to fit.
That "does not reproduce in a narrow desktop window" detail ended up being one of the key clues that cracked the case.
1. Reproduce first, explain later
At first I did not rush to change code. Instead I asked the agent to reproduce the problem under real mobile device emulation, and to give me verifiable numbers rather than an explanation.
How you reproduce it matters a lot:
- ❌ Dragging a desktop browser window narrow - this never triggers mobile layout-viewport behavior, so it can never reproduce the bug
- ✅ Creating a mobile context (
viewport: 390×844,deviceScaleFactor: 3,isMobile: true,hasTouch: true) so it renders under real device rules
Once reproduced, the very first set of numbers already told the whole story:
| Measurement | Observed |
|---|---|
documentElement.clientWidth (screen) | 390 |
visualViewport.width | 390 |
window.innerWidth (layout viewport) | 482 |
documentElement.scrollWidth | 482 |
document.body width | 390 |
measured 100vw | 390 |
The screen is only 390px, yet window.innerWidth is 482px. The document is 92px wider than the screen, so the mobile browser widened the layout viewport to fit the widest content - that is the source of the entire problem.
2. Why the header looked fine but the modal did not
The most counterintuitive part is that the header in the screenshot was completely normal (hamburger menu, centered title, dropdown chevron on the right, all exactly where they should be) - only the modal was misplaced. The reason is that normal flow and position: fixed use different reference frames:
screen width = 390 CSS px layout viewport = 482 CSS px
+------------------------------+ +--------------------------------------+
| body / header at 390px | | .modal__backdrop fixed inset-0 |
| (so the header looks fine) | | width:100% -> 482px wide |
+------------------------------+ | +--------------------------------+ |
| | .modal__dialog w-full | |
^ modal right edge 465 (+75px) | | max-w-md = 448px (size="md") | |
| | x=17 .............. right=465 | |
| +--------------------------------+ |
+--------------------------------------+
Concretely, for the HeroUI v3 Modal:
.modal__backdropisfixed inset-0 w-full, and its100%resolves against the initial containing block (the widened 482px layout viewport), not the screen;.modal__dialogdefaults tosize="md", i.e.max-w-md(448px). The container's content box is 482 − 32 (p-4) = 450 ≥ 448, so the dialog takes the full 448px;- the
sm:max-w-smI had written in the component only applies at ≥640px, so on a 390px screen it does nothing at all.
The result is a 448px-wide modal centered on a 482px reference frame, with its right edge landing at 465px - 75px past the screen, and cut off.
3. What actually widened the document
Next I ran an element-by-element sweep: find every element whose right edge exceeds the screen width and that is not inside a scroll container.
| Element | Width | Notes |
|---|---|---|
GFM <table> (API endpoint table) | 466px | the only element breaking the document (scrollWidth 482 is it) |
| Bare URL link | 385px | would break the document on its own below 385px |
<pre> code block | 554 - 1015px | fine - CodeBlock is itself a horizontal scroll container |
I also swept the whole site: across 22 pages (6 posts each in zh/en plus home, list, about, category, and tag pages) at 390px, only one post overflowed - this Modbus post with the API table.
The root cause is mundane: mdx-components.tsx maps img, a, pre, and code, but does not map table; globals.css has no rule targeting .prose table either. So the GFM table's min-content width simply broke the document open.
4. Three candidates that measurement rejected
The most valuable part of the investigation was actually the candidates that "looked perfectly reasonable but died on first contact with a measurement". Every candidate was verified by injecting styles first, and only then considered for the codebase:
Candidate 1: html { overflow-x: clip } - ineffective.
The idea was to "forbid horizontal overflow at the root". Measured, window.innerWidth was still 482 and the modal still overflowed. The browser does not consult overflow when it computes the layout viewport width.
Candidate 2: cap only the modal's width - ineffective.
This was the agent's first suggestion, and the one I liked most at first: max-w-[calc(100vw-2rem)]. Measured, the modal did get narrower (448 → 358), but it was still centered on the 482px reference frame: x=62, right edge 420 - still off screen. The real problem was never "the modal is too wide", it was "the centering reference frame is wrong".
Candidate 3: fix the table with pure CSS - had side effects.
.prose table { display: block; max-width: 100%; overflow-x: auto } is a very widely circulated "scrollable table" recipe, and measured, it did eliminate the overflow. But it turns the table's anonymous table box into shrink-to-fit, so the table no longer fills the column: on desktop the measured table width shrank from 953px to 849px, and the column widths went from 67/390/497 to 59/347/443. That is a change nobody asked for, but every reader would see it.
5. The final fix
Three changes in the end.
1. Wrap MDX tables in a scroll container (mdx-components.tsx)
table: ({ children, ...rest }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className="overflow-x-auto">
<table {...rest}>{children}</table>
</div>
),
The key is to wrap the table rather than reshape the table itself: the table stays display: table, so the typography plugin's width: 100% fill and its vertical margins are completely unchanged (measured margins before and after are 12px / 48px), and the overflow is handed to the outer div to scroll.
2. Let over-long tokens break (app/globals.css)
.prose :where(a, :not(pre) > code):not(:where([class~='not-prose'] *)) {
overflow-wrap: anywhere;
}
anywhere is required here - break-word will not do: only anywhere participates in min-content size calculation, which means only it can make the outer box (and the document) actually shrink back.
3. Make the backdrop follow the screen (ShareButton.tsx / SearchDialog.tsx)
<Modal.Backdrop className="max-w-[100vw]">
There is a key measurement here: in that state 100vw = 390 (the screen) while 100% / innerWidth = 482 (the widened layout viewport). So a vw-based cap works and a 100%-based cap does not. And max-width can only ever narrow the width, so it is completely inert when the page does not overflow - zero risk on desktop.
As an aside, HeroUI's own drawer panel does exactly this: .drawer__dialog[data-placement="left"] uses w-80 max-w-[85vw]. I basically copied its homework.
6. Verification
Before and after (mobile device emulation + desktop):
| Scenario | Before | After |
|---|---|---|
390px: window.innerWidth | 482 | 390 |
| 390px: share modal | 448px, right edge 465 (75px overflow) | 358px, right edge 374 ✅ |
| 390px: search modal | 450px, right edge 466 (76px overflow) | 358px, right edge 374 ✅ |
| 320px: share / search modal | 448 / 450px, right edge 465 / 466 (145 / 146px overflow) | 288px, right edge 304 ✅ |
| Desktop: share / search modal | 384 / 592 | 384 / 592 (unchanged) |
| Desktop: table column widths | 67 / 390 / 497 | 67 / 390 / 497 (unchanged) |
| All 22 pages × 390/360/320 | only the Modbus post, both locales | zero overflow |
pnpm build | - | all routes still ● (SSG) |
I also folded the conclusions into three OpenSpec capability specs - mdx-content, post-share, and post-search (new requirements + scenarios) - and openspec validate --specs passes 13/13.
7. Takeaways
- Mobile has several different "screen widths", and picking the wrong one produces a fallback that looks reasonable but does nothing. Here it was
100vw(390) versus100%/innerWidth(482): swap one unit inside the same CSS rule and the fix goes from "effective" to "completely ineffective". - Horizontal page overflow is not just a cosmetic problem. It widens the layout viewport, which in turn misplaces every
position: fixedoverlay (modals, drawers, backdrops) - a small "content layout" wart that ends up looking like "a broken component". - There is a gap worth reporting upstream: React Aria's backdrop height follows the visual viewport (
--visual-viewport-height), while its width isw-full(the layout viewport). The two disagree, which means every Modal / Drawer has to add its own width cap. - Measurement trap: HeroUI's modal enter animation carries
zoom-in-105, i.e. it appliestransform: scale(1.05)to the container. In that stategetBoundingClientRect()returns the scaled values (448 → 470), so you must measure after the animation finishes (>250ms) or the entire dataset is wrong. - The right way to use an agent for bug hunting: make it produce verifiable numbers first, not explanations. Its first proposal (just capping the modal width) was wrong, and what refuted it was the very
x=62 / right edge 420it had measured itself. Making "every candidate must be verified by injection and measurement" a hard requirement is more reliable than asking it to "give the correct fix".