Playwright Check Horizontal Overflow: A Mobile Audit Script
Playwright script that screenshots pages at 1440 px and 390 px, fails on scrollWidth > clientWidth or console errors, and names the overflowing element.

The check itself is one line of JavaScript run inside the page: compare document.documentElement.scrollWidth to document.documentElement.clientWidth. If scrollWidth is larger, the page is wider than the viewport and a phone will scroll sideways. Playwright gives you page.evaluate() to run that comparison, set_viewport_size() to pretend you're on a phone, and a console listener to catch JavaScript errors in the same pass.
That's what I do for every view on this site: screenshot it at 1440 px and 390 px with Playwright, and fail the check if scrollWidth exceeds clientWidth or there is any console error. The rest of this post is the script, how to find which element is responsible, and the cases where the number lies to you.
Why scrollWidth versus clientWidth is the right test#
clientWidth on the root element is the width of the viewport minus any vertical scrollbar. scrollWidth is how wide the element would need to be to show all of its content without scrolling horizontally. On a healthy page the two are equal. The moment any descendant sticks past the right edge, scrollWidth grows and the browser offers a horizontal scrollbar, or on a phone, a page that drifts sideways when you swipe up.
The MDN page on Element.scrollWidth has the exact definition. Two details matter for an audit:
- Both values are integers, so a sub-pixel overflow rounds away. That's fine. Nobody notices half a pixel.
- Measure
document.documentElement, notdocument.body. The body can be narrower than the viewport, or have its own overflow rules, and you'll get a number that doesn't match what the user sees.
You want to run this at a real phone width. 390 px matches a common modern iPhone viewport and is narrow enough to expose most layout mistakes. 1440 px is a typical laptop width and catches the desktop-only breakages like a wide table in a narrow sidebar.
The Playwright audit script#
This is the Python version. It opens a fresh page per URL and viewport, attaches console and page-error listeners before navigating, takes a full-page screenshot, measures overflow, and exits non-zero if anything failed.
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
BASE = "http://localhost:3000"
PATHS = ["/", "/blog/", "/about/"]
VIEWPORTS = [(1440, 900), (390, 844)]
Path("shots").mkdir(exist_ok=True)
failures = []
with sync_playwright() as p:
browser = p.chromium.launch()
for path in PATHS:
for width, height in VIEWPORTS:
page = browser.new_page(viewport={"width": width, "height": height})
errors = []
page.on(
"console",
lambda msg, errors=errors: errors.append(msg.text)
if msg.type == "error" else None,
)
page.on("pageerror", lambda exc, errors=errors: errors.append(str(exc)))
page.goto(BASE + path, wait_until="load")
page.evaluate("() => document.fonts.ready")
slug = path.strip("/").replace("/", "-") or "home"
page.screenshot(path=f"shots/{slug}-{width}.png", full_page=True)
overflow = page.evaluate(
"() => document.documentElement.scrollWidth"
" - document.documentElement.clientWidth"
)
if overflow > 0:
failures.append(f"{path} @ {width}px: overflows by {overflow}px")
for err in errors:
failures.append(f"{path} @ {width}px: console error: {err}")
page.close()
browser.close()
for line in failures:
print(line)
sys.exit(1 if failures else 0)
A few choices worth explaining:
- A new page per check. Listeners attached with
page.on()stay attached. If you reuse one page across viewports, errors from the first run leak into the second. A fresh page keeps each result clean. - Listeners before
goto. Errors fired during initial script execution are the ones you care about most. Attach first, navigate second. document.fonts.ready. Web fonts that load late can change text width. Waiting for them makes the measurement match what the user ends up with.- Screenshots regardless of result. When the check fails, you want the picture of the failing viewport already on disk. The Playwright screenshots docs cover
full_page, clipping and element screenshots if you want to narrow the capture.
Run it with the usual setup:
pip install playwright
playwright install chromium
python audit.py
The exit code is what makes it useful in CI. A non-zero exit fails the job, and the printed lines tell you which path, which width, and whether it was overflow or a console error.
Finding the element that causes horizontal overflow#
Knowing a page overflows by 48 px is half the job. The other half is the selector. Walk every element in the body, take its bounding box, and keep the ones whose right edge passes the viewport. Run this with page.evaluate() right after the overflow check:
offenders = page.evaluate("""() => {
const limit = document.documentElement.clientWidth;
const out = [];
for (const el of document.querySelectorAll('body *')) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.right > limit + 1) {
out.push({
tag: el.tagName.toLowerCase(),
id: el.id,
cls: el.getAttribute('class') || '',
right: Math.round(r.right),
});
}
}
return out.slice(0, 10);
}""")
The + 1 tolerance ignores rounding noise. getAttribute('class') instead of className avoids a surprise with inline SVG, where className is an object rather than a string. Capping at ten keeps the output readable, because one wide element usually drags a chain of ancestors past the edge with it. The first entry in document order is normally the container; the last few are the real culprit.
The filtering logic is simple enough to see without a browser. This is the same comparison applied to a handful of fake rectangles:
const clientWidth = 390;
const rects = [
{ selector: "header.site", right: 390 },
{ selector: "pre.code-block", right: 612 },
{ selector: "img.hero", right: 390 },
{ selector: "table.pricing", right: 455 },
{ selector: "span.badge", right: 391 },
];
const offenders = rects
.filter((r) => r.right > clientWidth + 1)
.map((r) => `${r.selector} sticks out by ${r.right - clientWidth}px`);
console.log(offenders.length ? offenders.join("\n") : "no overflow");
One caveat: getBoundingClientRect() is relative to the current scroll position. The audit runs right after load with the page scrolled to the top-left, so the numbers line up with the viewport. If you scroll the page before measuring, add window.scrollX back in.
Catching console errors in the same pass#
Horizontal overflow and JavaScript errors look unrelated, but they fail the same way: silently, on a device you didn't test. Since the browser is already open, collecting errors costs nothing.
Playwright exposes two events. console fires for every console.* call, and you filter on msg.type == "error". pageerror fires for uncaught exceptions, which never go through console.error and would otherwise be missed. The script above listens to both.
If you'd rather treat warnings as failures too, change the filter to msg.type in ("error", "warning"). Expect noise from third-party scripts if you do, and decide whether that noise is a signal you want.
Running it as a Playwright test instead#
If your project already uses Playwright Test in Node, the overflow check fits as a normal assertion. Generate one test per viewport so the report names the failing width:
import { test, expect } from "@playwright/test";
const URL = "http://localhost:3000/";
for (const width of [1440, 390]) {
test(`no horizontal overflow at ${width}px`, async ({ page }) => {
const errors = [];
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
page.on("pageerror", (err) => errors.push(err.message));
await page.setViewportSize({ width, height: 900 });
await page.goto(URL);
await page.evaluate(() => document.fonts.ready);
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
);
expect(overflow).toBe(0);
expect(errors).toEqual([]);
});
}
Note the API difference: in Node, msg.type() and msg.text() are methods, while the Python bindings expose them as properties. Mixing the two is the most common reason a copied snippet does nothing.
The Playwright emulation docs describe the device presets if you'd rather use a named phone with its real user agent and device scale factor than a bare viewport size. For an overflow check the bare size is enough, and it keeps screenshots at 1x so they're small.
Where this check can mislead you#
The number is honest, but the moment you take it matters, and a few CSS patterns make it lie.
Measuring too early. Lazy-loaded images, embeds and client-rendered components arrive after load. If the thing that overflows is injected by JavaScript two seconds later, the check passes and the user still gets a sideways page. Wait for the specific element you know arrives late, with page.wait_for_selector(), rather than relying on networkidle, which Playwright's own docs discourage for tests and which never settles on pages with polling.
overflow-x: hidden on the body. This is the fix people reach for first. It hides the scrollbar, and on some pages that's all anyone wants. But it doesn't change where the element sits, and content past the edge is simply cut off. The offender scan above still finds it, because getBoundingClientRect() doesn't care about clipping. Treat a hidden overflow as a symptom you've muted, not a bug you've closed.
100vw on desktop. At 1440 px with a visible vertical scrollbar, 100vw includes the scrollbar width, so an element set to width: 100vw overflows by exactly that width. Headless Chromium often has no visible scrollbar, so the check passes in CI and fails on a real desktop. Use 100% for full-width blocks and keep vw for things that genuinely want the viewport.
Fixed or absolute elements. A cookie banner positioned off-screen as part of a slide-in animation sits past the right edge on purpose. It counts toward scrollWidth unless it's inside a clipped container. Either exclude it from the scan by class, or wait until its animation ends before measuring. If you're pausing animations for the measurement, the prefers-reduced-motion test setup covers how to emulate that media query in Playwright.
Fonts and width. If you skip document.fonts.ready, text can be measured in a fallback font that's narrower or wider than the real one. A white-space: nowrap nav that fits in the fallback can overflow once the webfont lands.
Fixing the usual causes once you have the selector#
Most offenders fall into a short list.
- Images and video without a ceiling. Add
max-width: 100%; height: auto;toimg, video, svg, iframe. This alone clears a large share of mobile overflow. - Code blocks and tables. A
preortableis as wide as its longest line. Wrap it in a container withoverflow-x: autoso the block scrolls on its own instead of dragging the page with it. - Long unbroken strings. URLs and hashes in body text won't wrap.
overflow-wrap: anywhereon the paragraph fixes it without touching the rest of the typography. white-space: nowrapon navigation. Fine at 1440 px, fatal at 390 px. Let it wrap, or switch to a scrollable strip at narrow widths.- Grid and flex children that refuse to shrink. Grid tracks and flex items default to
min-width: auto, which means they won't get narrower than their content. Setmin-width: 0on the child, or useminmax(0, 1fr)in the grid template. - Fixed pixel widths. A
width: 600pxcard inside a 390 px viewport is 210 px of overflow. Usemax-widthinstead, or let the width come from the container. If the card needs to change layout based on its own available width rather than the viewport, container queries solve that cleanly. - Negative margins and transforms. A decorative element pulled left with
margin-left: -2remor shifted withtranslateXcan push past the right edge once its parent is narrow. Clip the parent withoverflow: hiddenon that one container, not on the body.
After each fix, rerun the script. The overflow number should drop to zero at both widths, and the screenshots give you a before-and-after without opening a phone.
Checklist#
- Measure
document.documentElement.scrollWidthminusclientWidthwithpage.evaluate(); fail on anything above zero. - Run at 1440 px and 390 px at minimum; add other widths only when a specific layout breaks between them.
- Open a fresh page per URL and viewport so console listeners don't leak between runs.
- Attach
consoleandpageerrorlisteners beforegoto, and fail on any error. - Wait for
document.fonts.readyand for any late-rendered element before measuring. - When it fails, scan
body *withgetBoundingClientRect()to get the selector, and read the last entries first. - Save the full-page screenshot whether or not the check passes.
- Don't paper over the result with
overflow-x: hiddenon the body; fix the element the scan names. - Exit non-zero on failure so CI blocks the deploy.
Quick answers
What does it mean when scrollWidth is greater than clientWidth?
The page content is wider than the viewport, so the browser can scroll horizontally. On a phone that shows up as the page drifting sideways when you swipe. Measure both on document.documentElement for a number that matches what users see.
How do I find which element is causing horizontal scroll?
Loop over every element in the body, call getBoundingClientRect(), and keep the ones whose right edge is past document.documentElement.clientWidth. The last entries in document order are usually the real culprit; the earlier ones are containers it dragged along.
Does overflow-x: hidden on the body fix horizontal overflow?
It hides the scrollbar but leaves the element sitting past the edge, where it's clipped. The bounding-box scan still finds it. Fix the element the scan names instead, and use overflow: hidden only on that one container if clipping is intended.
Which viewport width should I use to test mobile overflow with Playwright?
390 px is a common modern phone width and narrow enough to expose most layout mistakes. Pair it with a desktop width like 1440 px to catch the 100vw and wide-table problems that only appear with a vertical scrollbar.
Did this solve it?
One tap. No account.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
On a computer? Scan the code with your phone wallet, or copy the address.
Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.
Discussion
Start the discussion
Next note