●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 43 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 43 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
The Page Was 90px Too Wide at 375px, and the Culprit Never Showed Up in querySelectorAll
When a pseudo-element causes horizontal overflow on phones, scanning elements finds nothing. A probe script that confirms the culprit by diff, and overflow fixes that keep sticky intact, with measurements.
I opened my site's billing page at phone width (375px), and the theme switch and menu at the right end of the header had slid off the screen.
document.documentElement.scrollWidth read 465px. The page was 90px wider than the screen, a factor of 1.24 (465 ÷ 375). A menu falling off-screen on the billing page, the one leading into checkout, hurts conversion directly.
I scanned every element for a right edge beyond the viewport. The scan returned zero hits.
The culprit was the ::after of a decoration I had attached to the first section of every page: a golden glow 540px wide. Pseudo-elements appear in neither querySelectorAll nor getBoundingClientRect.
Here I rebuild the same symptom on a small page, write a probe script that pins the cause down to one element including pseudo-elements, and compare fixes. overflow-x: hidden looks like it works, then breaks sticky somewhere else.
A minimal page that reproduces it
Before touching the production page, I rebuilt the structure in about 20 lines. The trigger was a rule that decorates "the first section of every page".
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>body{margin:0;font:16px/1.7 sans-serif}header{display:flex;justify-content:space-between;align-items:center;padding:12px 15px;border-bottom:1px solid #ccc}header button{width:40px;height:40px}main>div{padding:0 15px}section{position:relative;padding:24px 0}/* decoration applied to the first section of every page */main > div > section:first-child::after{ content:"";position:absolute;top:0;left:50%;width:540px;height:200px; transform:translateX(-50%);background:radial-gradient(closest-side,gold,transparent); pointer-events:none;z-index:-1}</style></head><body><header><span>Site</span><button id="menu">≡</button></header><main><div><section><h1>Membership</h1><p>The first section.</p></section><section><h2>Plan</h2><p>The second section.</p></section></div></main></body></html>
The section is 345px wide (375px minus 15px of padding on each side). Centering a 540px glow on its midpoint at 187.5px puts the right edge at 187.5 + 270 = 457.5px.
Measured in headless Chromium at 375×800, scrollWidth was 458px. Production read 465px; the difference comes from section width and padding, and the structure is the same.
The element scan returns zero
The first thing most of us write is a loop that looks for elements whose right edge sits beyond the viewport.
const vw = document.documentElement.clientWidth;[...document.body.querySelectorAll('*')] .filter(el => el.getBoundingClientRect().right > vw + 0.5);// → [] (zero hits on the repro page and in production)
::after is not a DOM element, so this loop never visits it. The result says "nothing overflows", and the investigation stalls with no cause in sight.
This is where I lost the most time. When the result is empty, I now suspect "something exists that the scan cannot reach" before I suspect my measuring method.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can run a probe script on your own page that pins down a horizontal-overflow culprit to one element, pseudo-elements (::before / ::after) included
✦You can decide between overflow-x hidden, clip, and a root-level rule without breaking sticky elements
✦You can hand Claude Code the measured facts instead of just saying 'it overflows', and skip several rounds of guess-and-patch
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Pseudo-element information is readable through getComputedStyle(el, '::after'). Width and position come back, but not the right edge. So I switched to a diff: hide each candidate with display: none and see whether scrollWidth shrinks.
// Probe: find what makes the page overflow horizontally, pseudo-elements included(() => { const vw = document.documentElement.clientWidth; const sw = () => document.documentElement.scrollWidth; const base = sw(); const out = { viewport: vw, scrollWidth: base, overflow: base - vw, elements: [], pseudos: [], confirmed: [] }; if (base <= vw) return out; // 1) Regular elements whose right edge is off-screen for (const el of document.body.querySelectorAll('*')) { const r = el.getBoundingClientRect(); if (r.width > 0 && r.right > vw + 0.5) { out.elements.push({ node: el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\s+/).join('.') : ''), right: Math.round(r.right) }); } } // 2) Pseudo-elements: invisible to querySelectorAll and getBoundingClientRect, so read computed style const cand = []; for (const el of document.body.querySelectorAll('*')) { for (const p of ['::before', '::after']) { const cs = getComputedStyle(el, p); if (cs.content === 'none' || cs.display === 'none') continue; const w = parseFloat(cs.width); if (!(w > 0)) continue; if (cs.position !== 'absolute' && cs.position !== 'fixed') continue; cand.push({ el, p, width: w, left: cs.left, transform: cs.transform }); } } // 3) Hide candidates one at a time and confirm by the change in scrollWidth (a diff, not a guess) let i = 0; for (const c of cand) { const key = 'probe' + i++; c.el.setAttribute('data-' + key, ''); const st = document.createElement('style'); st.textContent = `[data-${key}]${c.p}{display:none !important}`; document.head.appendChild(st); const after = sw(); st.remove(); c.el.removeAttribute('data-' + key); out.pseudos.push({ host: c.el.tagName.toLowerCase() + (c.el.className && typeof c.el.className === 'string' ? '.' + c.el.className.trim().split(/\s+/).join('.') : ''), pseudo: c.p, width: c.width, left: c.left, scrollWidthWithout: after, delta: base - after }); if (base - after > 0) out.confirmed.push(`${c.el.tagName.toLowerCase()}${c.p}: hiding it takes ${base} to ${after}`); } return out;})()
Paste it into the browser devtools console and it runs as is. On the repro page it printed this:
{ "viewport": 375, "scrollWidth": 458, "overflow": 83, "elements": [], "pseudos": [{"host":"section","pseudo":"::after","width":540,"left":"172.5px","scrollWidthWithout":375,"delta":83}], "confirmed": ["section::after: hiding it takes 458 to 375"]}
elements stays empty, and confirmed says that hiding section::after brings the width from 458 back to 375. (Under mobile emulation the numbers were 1px higher at 459, with the same conclusion.) What I like about this approach is that it says "hiding it fixed it", not "this looks suspicious".
The candidate filter keeps only absolute and fixed pseudo-elements, because static ones sit in normal flow and tend to fit their parent. If you suspect a large inline pseudo-element, drop that condition.
Why clientWidth
The baseline is document.documentElement.clientWidth, not window.innerWidth. Mobile emulation shows why.
State (375px, mobile emulation)
innerWidth
clientWidth
scrollWidth
Before the fix
459
375
459
overflow-x: clip on main
375
375
375
overflow-x: hidden on body
459
375
459
When something overflows, on mobile innerWidth (the layout viewport) widens to 459. If you compare against innerWidth meaning to compare against the screen, you get "no overflow" by mistake. Keep clientWidth as the baseline.
The third row is easy to miss. Adding overflow-x: hidden to body left innerWidth at 459 on mobile. The scrollbar disappears, but the widened layout viewport stays. I now check "it no longer shows" and "the width is actually fixed" as two separate things.
Comparing fixes by measurement
I applied each fix to the same repro page and measured scrollWidth and sticky behavior. The page has a table of contents (position: sticky; top: 0) inside main. If it sits at 0 after scrolling 600px, sticky is alive.
Fix
scrollWidth
TOC position after scroll
Verdict
Nothing
458
0
Overflows
overflow-x: clip on main
375
0
Fixed, sticky intact
overflow-x: hidden on main
375
-347
Fixed, but sticky dies
overflow-x: clip on html
458
0
Not fixed
overflow-x: hidden on body
458
0
Not fixed (by the number)
Scope the decoration with :has()
375
0
Fixed at the root
Two readings from the table
Two results surprised me.
First, overflow-x: hidden on main made the sticky table of contents drift away: -347px after scrolling. hidden creates a scroll container, so sticky children resolve against main and stop following the page scroll. clip creates no scroll container, so the position stayed at 0 with the same 375px width.
Second, rules on html and body left scrollWidth at 458. Browsers propagate the root's overflow to the viewport, so the effect was "hide the scrolling" rather than "remove the overflow".
Fix in two layers
In my case, the reading of these results is: overflow-x: clip on main is insurance, and the real fix is narrowing what the decoration applies to. For production, I recommend adding clip first to stop the symptom, then narrowing the target.
/* Before: applies to the first section of every page */main > div > section:first-child::after { /* 540px glow */ }/* After: only sections that carry the decoration class */main > div > section:first-child:has(.hero-glow)::after { /* 540px glow */ }/* Insurance: cut the overflow without creating a scroll container */main { overflow-x: clip; }
Pin down where the overflow comes from before hiding it. Once you do, what to verify after the fix follows naturally.
Measure it every time with Playwright
A fix holds only until the next decoration is added. A script that measures at 375px before deploy works as prevention.
import json, sysfrom playwright.sync_api import sync_playwrightprobe = open('probe.js').read() # the probe script above, saved to a fileurl = sys.argv[1]with sync_playwright() as p: b = p.chromium.launch() ctx = b.new_context(viewport={'width': 375, 'height': 800}, is_mobile=True, has_touch=True, device_scale_factor=2) pg = ctx.new_page() pg.goto(url) r = pg.evaluate(probe) print(json.dumps(r, ensure_ascii=False)) b.close() sys.exit(1 if r['overflow'] > 0 else 0) # exit code 1 when anything overflows
Failing on the exit code lets it serve as a CI step. Start with the pages where decorations pile up, such as billing and list pages, and widen from there.
One caution: do not throw the output away before sys.exit. Piping the result into | head drops the exit code and everything looks like a success. It is the same family of trap as reading only the first 250 lines of Grep output: feeling safe after seeing only the beginning.
How to hand it to Claude Code
With measurements in hand, the request changes. Saying "it overflows on phones" produces a list of guesses. Adding the observation narrows it to one candidate.
At 375px, scrollWidth is 465 (clientWidth 375).An element scan returned 0 hits. Hiding pseudo-elements one at a timeand diffing shows the width returns to 375 only when I hidemain > div > section:first-child::after (width 540px).Limit where this ::after applies to sections that carry the decoration class.Do not use overflow-x: hidden because it breaks sticky; if insurance is needed, use clip.
Written like this, the request carries the confirmed facts, the fix to avoid, and the role of the insurance. That should cut the back-and-forth of asking for the same fix again and again.
The idea of checking through a script is the same direction as letting a script mediate pbxproj edits: instead of following something by eye, make the procedure return the same answer every time.
Pitfalls
Using innerWidth as the baseline. On mobile, innerWidth widens together with the overflow. Use clientWidth
Dropping pseudo-elements without looking at position. A wide static pseudo-element can overflow too. If nothing is found, remove the filter and rerun
Declaring victory after hidden. The number returning to 375 and sticky or position: fixed children staying healthy are separate facts. Measure the position after scrolling as well
Stopping at a rule on html or body. On this repro page it left scrollWidth unchanged. Behavior varies with structure and browser, so measure on your own page
Several pseudo-elements overflowing at once. If hiding one leaves delta at 0, more than one may be sticking out. Hide the zero-delta candidates together and measure again
The repro page here contains a single pseudo-element. Pages where several overlap may need a "hide them together" step added.
Next step
Open your own page at 375px and paste the probe into the console. If overflow is 0, you are safe for now. If it is above 0, the line in confirmed is the first place to look.
Keep that one line of output somewhere. It becomes your baseline the next time you add a decoration.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.