How to find what causes horizontal scroll on mobile
Compare document.documentElement.scrollWidth with clientWidth, then walk every element and report each one whose getBoundingClientRect().right passes clientWidth. On the page below, at a 390 px viewport, that named three boxes: a width:100vw hero inside a padded wrapper, a negative margin, and an off-canvas menu parked at left:100%.
Why check this
Run this on every release that touches layout, and again after a design system upgrade, because sideways scroll arrives from a component nobody edited. It is also the first check to run against a bug report that says the page slides under the thumb.
The defect it prevents is a page that drags 280 px to the right on a 390 px screen. The fixed header stops covering the viewport, the sticky action bar sits half off the edge, and a tester who only reads the page top to bottom never sees it. Naming the element matters more than detecting the scroll: the detection takes one line, and the hunt for the culprit is what costs an afternoon.
Prerequisites
- Chrome 152 and Node 22, with
npm i puppeteer-core. The driver uses the installed Chrome and downloads no browser. - Every figure on this page is one capture on one machine, taken on 2026-09-12. Widths move with the Chrome version and the platform scrollbar.
- Element.getBoundingClientRect returns viewport-relative coordinates, which is what makes the comparison work.
- Save this file as
overflow-server.mjs. It plants three separate causes in one page, and it is the target for every step below. Port 8863 was free here; pick another if it is taken.
// overflow-server.mjs run: node overflow-server.mjs stop: Ctrl-C
import http from 'node:http';
const rows = Array.from({ length: 40 }, (_, i) => `<p>Line ${i + 1} of the order history.</p>`).join('\n ');
const page = `<!doctype html><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>overflow probe</title>
<style>
*{box-sizing:border-box} body{margin:0;font:16px/1.5 system-ui}
.wrap{padding:0 16px}
.hero{width:100vw;background:#eef;padding:24px 0;text-align:center}
.promo{margin:0 -24px;background:#fee;padding:12px}
#drawer{position:absolute;top:0;left:100%;width:280px;height:100%;background:#333;color:#fff}
</style>
<div class="wrap">
<div class="hero">hero, width:100vw</div>
<h1>Order summary</h1>
<p class="promo">promo strip, margin:0 -24px</p>
${rows}
</div>
<nav id="drawer">off-canvas menu, left:100%</nav>
`;
http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(page);
}).listen(8863, () => console.log('overflow probe on http://localhost:8863/'));
Steps
- Step 1.
Start the target page. Stop it with Ctrl-C at the end.
node overflow-server.mjsoverflow probe on http://localhost:8863/ - Step 2.
Measure the overflow and name every element that causes it. Save this as
overflow-find.mjsand runnode overflow-find.mjs.// overflow-find.mjs import { launch } from 'puppeteer-core'; const browser = await launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true }); const page = await browser.newPage(); await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }); await page.goto('http://localhost:8863/', { waitUntil: 'networkidle2' }); console.log(await page.evaluate(() => { const d = document.documentElement; const out = [`documentElement.scrollWidth ${d.scrollWidth}`, `documentElement.clientWidth ${d.clientWidth}`, `window.innerWidth ${innerWidth}`, `scrollWidth > clientWidth ${d.scrollWidth > d.clientWidth}`, `scrollWidth > innerWidth ${d.scrollWidth > innerWidth}`, '', 'elements whose right edge passes clientWidth:']; for (const el of document.querySelectorAll('*')) { const r = el.getBoundingClientRect(); if (r.right <= d.clientWidth + 0.5) continue; const name = el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\s+/).join('.') : ''); const cs = getComputedStyle(el); out.push(` ${name.padEnd(14)} left=${Math.round(r.left)} right=${Math.round(r.right)} width=${cs.width} margin-left=${cs.marginLeft} position=${cs.position}`); } return out.join('\n'); })); await browser.close();documentElement.scrollWidth 670 documentElement.clientWidth 390 window.innerWidth 670 scrollWidth > clientWidth true scrollWidth > innerWidth false elements whose right edge passes clientWidth: div.hero left=16 right=406 width=390px margin-left=0px position=static p.promo left=-8 right=398 width=406px margin-left=-24px position=static nav#drawer left=390 right=670 width=280px margin-left=0px position=absoluteThree causes, each with its own fix.
div.herois 390 px wide because100vwresolves to the viewport, and it starts at x=16 because its parent has 16 px of padding, so it ends 16 px late.p.promois 406 px wide frommargin: 0 -24px.nav#draweris the off-canvas menu, parked one full viewport to the right.Note line five.
scrollWidth > innerWidthis the comparison most snippets use, and here it isfalseon a page that overflows by 280 px. - Step 3.
Check what the outline trick would have shown. Add the usual debug rule and measure where each red box lands. Save this as
overflow-outline.mjs.// overflow-outline.mjs import { launch } from 'puppeteer-core'; const browser = await launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true }); const page = await browser.newPage(); await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }); await page.goto('http://localhost:8863/', { waitUntil: 'networkidle2' }); await page.addStyleTag({ content: '* { outline: 1px solid red }' }); console.log(await page.evaluate(() => { const vw = document.documentElement.clientWidth; const lines = [`the viewport shows x = 0 to ${vw}. Each overflowing box, split at that edge:`]; for (const el of document.querySelectorAll('*')) { const r = el.getBoundingClientRect(); if (r.right <= vw + 0.5) continue; const name = el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/\s+/).join('.') : ''); const on = Math.max(0, Math.min(r.right, vw) - Math.max(r.left, 0)); const off = r.right - Math.max(r.left, vw); lines.push(` ${name.padEnd(14)} on screen ${Math.round(on)}px, past the edge ${Math.round(off)}px, red outline drawn at x=${Math.round(r.right)}`); } return lines.join('\n'); })); await browser.close();the viewport shows x = 0 to 390. Each overflowing box, split at that edge: div.hero on screen 374px, past the edge 16px, red outline drawn at x=406 p.promo on screen 390px, past the edge 8px, red outline drawn at x=398 nav#drawer on screen 0px, past the edge 280px, red outline drawn at x=670The right outline of every culprit is drawn beyond x=390, which is off the screen. The drawer has no pixel inside the viewport at all. Scanning the rendered page for a red box finds nothing, and the two boxes that do show look correctly aligned, because their left edges are where the design says.
- Step 4.
Separate the
100vwcase from the scrollbar. Run the same measurement at a desktop width in three launch modes.// overflow-desktop.mjs import { launch } from 'puppeteer-core'; const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; const runs = [ { label: 'headless ', headless: true }, { label: 'headless, flag off ', headless: true, ignoreDefaultArgs: ['--hide-scrollbars'] }, { label: 'visible window ', headless: false }, ]; for (const { label, ...opts } of runs) { const browser = await launch({ executablePath: CHROME, defaultViewport: { width: 1280, height: 800 }, ...opts }); const page = await browser.newPage(); await page.goto('http://localhost:8863/', { waitUntil: 'networkidle2' }); console.log(label + await page.evaluate(() => { const d = document.documentElement, hero = document.querySelector('.hero'); return `innerWidth ${innerWidth} clientWidth ${d.clientWidth} scrollbar ${innerWidth - d.clientWidth}px ` + `.hero width ${getComputedStyle(hero).width} .hero right ${Math.round(hero.getBoundingClientRect().right)}`; })); await browser.close(); }headless innerWidth 1280 clientWidth 1280 scrollbar 0px .hero width 1280px .hero right 1296 headless, flag off innerWidth 1280 clientWidth 1265 scrollbar 15px .hero width 1280px .hero right 1296 visible window innerWidth 1280 clientWidth 1265 scrollbar 15px .hero width 1280px .hero right 1296100vwresolves toinnerWidth, which includes the classic scrollbar. Where the scrollbar exists, a block can use 1265 px while100vwhands it 1280, sowidth:100vwoverflows by 15 px with no other mistake in the CSS. The first run misses that, because Puppeteer starts Chrome with--hide-scrollbars. - Step 5.
Confirm what
overflow-x: hiddenchanges. Load the page in a 390 px window, measure, add the rule, measure again.// overflow-mask.mjs import { launch } from 'puppeteer-core'; const browser = await launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: false, defaultViewport: { width: 390, height: 700 } }); const page = await browser.newPage(); await page.goto('http://localhost:8863/', { waitUntil: 'networkidle2' }); const probe = () => page.evaluate(() => { scrollTo(500, 0); const d = document.documentElement, x = Math.round(scrollX); scrollTo(0, 0); return `scrollWidth ${d.scrollWidth} clientWidth ${d.clientWidth} scrollX after scrollTo(500,0) ${x} ` + `horizontal scrollbar ${innerHeight - d.clientHeight}px tall #drawer right ${Math.round(document.querySelector('#drawer').getBoundingClientRect().right)}`; }); console.log('as served ' + (await probe())); await page.addStyleTag({ content: 'html, body { overflow-x: hidden }' }); console.log('overflow-x hidden ' + (await probe())); await browser.close();as served scrollWidth 655 clientWidth 375 scrollX after scrollTo(500,0) 280 horizontal scrollbar 15px tall #drawer right 655 overflow-x hidden scrollWidth 655 clientWidth 375 scrollX after scrollTo(500,0) 280 horizontal scrollbar 0px tall #drawer right 655One number moved. The scrollbar stopped taking 15 px of height, so the page looks fixed.
scrollWidth, the element rect and programmatic scrolling are identical, which means the layout defect is untouched and any assertion you write still fails.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| scrollWidth equals clientWidth | No element passes the right edge at this width | Repeat at the widths from How to find css breakpoints of a website before calling it clean |
| One element, position: absolute, far to the right | An off-canvas panel that was never taken out of flow | Add overflow: hidden to the panel's own container, not to body |
| width equal to the viewport on a padded parent | 100vw inside a wrapper with side padding | Replace 100vw with 100%, or use margin-inline: calc(50% - 50vw) |
| A negative margin-left or margin-right | A full-bleed strip wider than its parent | Cap it with max-width: 100% or move the bleed to the wrapper |
| Overflow at desktop width only, with a scrollbar present | 100vw includes the scrollbar, clientWidth does not | Measure innerWidth - clientWidth, and test in a window that has a scrollbar |
Common mistakes
What to check next
- How to test responsive design: the width sweep this check belongs inside.
- How to find css breakpoints of a website: the widths worth repeating the measurement at.
- How to check viewport meta tag: a missing or scaled viewport tag changes every number above.
- How to check which srcset image the browser loaded: an image that ignores its container is a common overflow source.
- How to test a page at 200 percent zoom: zoom narrows the viewport and exposes the same overflow.
FAQ
How do I find the element causing a horizontal scrollbar?
Walk document.querySelectorAll('*') and keep every element whose getBoundingClientRect().right is greater than document.documentElement.clientWidth. Print the name, the rect and the computed width, as step 2 does. The list is usually one to three elements.
Why does width: 100vw cause horizontal scroll?
100vw is the viewport width including the classic scrollbar, while a block element can only use clientWidth. Measured here, that gap was 15 px in a window with a scrollbar. Inside a parent with side padding the same rule adds the padding on top.
Does overflow-x: hidden fix horizontal scroll?
It hides the scrollbar. The measurement in step 5 was identical before and after, and programmatic scrolling still reached x=280. Use it to contain a known off-canvas panel on that panel's own wrapper, not on html or body to close a ticket.
Can I find the cause without DevTools?
Yes. The script in step 2 runs headless, prints the culprits and fits in a CI job. Read the fourth pitfall first: hidden scrollbars change what a headless run can see.
Verified
Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny