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

// 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

  1. Step 1.

    Start the target page. Stop it with Ctrl-C at the end.

    node overflow-server.mjs
    
    overflow probe on http://localhost:8863/
  2. Step 2.

    Measure the overflow and name every element that causes it. Save this as overflow-find.mjs and run node 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=absolute

    Three causes, each with its own fix. div.hero is 390 px wide because 100vw resolves to the viewport, and it starts at x=16 because its parent has 16 px of padding, so it ends 16 px late. p.promo is 406 px wide from margin: 0 -24px. nav#drawer is the off-canvas menu, parked one full viewport to the right.

    Note line five. scrollWidth > innerWidth is the comparison most snippets use, and here it is false on a page that overflows by 280 px.

  3. 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=670

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

  4. Step 4.

    Separate the 100vw case 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 1296

    100vw resolves to innerWidth, which includes the classic scrollbar. Where the scrollbar exists, a block can use 1265 px while 100vw hands it 1280, so width:100vw overflows by 15 px with no other mistake in the CSS. The first run misses that, because Puppeteer starts Chrome with --hide-scrollbars.

  5. Step 5.

    Confirm what overflow-x: hidden changes. 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 655

    One 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

Sign: The check reports no overflow on a phone viewport that visibly scrolls sideways.Cause: The snippet compares scrollWidth with window.innerWidth. Under Chrome mobile emulation at 390 px this page reported innerWidth 670, the same as scrollWidth, so the comparison was false while clientWidth was 390. Compare against document.documentElement.clientWidth, which stayed at the viewport width.
Sign: Adding a red outline to every element shows a tidy page.Cause: The outline that matters is drawn on the part of the box that is off the screen. Measured here, the drawer had 0 px inside the viewport and its outline landed at x=670, and the other two culprits drew their right outline at 406 and 398, all past the 390 px edge.
Sign: The headless run in CI is clean and testers still report a sideways drag.Cause: Puppeteer starts Chrome with --hide-scrollbars, confirmed on this machine from browser.process().spawnargs, so innerWidth and clientWidth were both 1280 and 100vw fitted. Pass ignoreDefaultArgs: ['--hide-scrollbars'] and the same headless run reported clientWidth 1265 against a 1280 px hero.
Sign: overflow-x: hidden on html or body is filed as the fix.Cause: It removes the scrollbar and nothing else. After the rule, scrollWidth was still 655 against clientWidth 375, the drawer still ended at x=655, and scrollTo(500,0) still moved the page to 280. The layout is unchanged, and content outside the viewport is now unreachable instead of reachable.

What to check next

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.

intermediate8 minpublished updated Maks Verny