How to Fix Horizontal Scroll on Mobile: Find the Culprit
Fix horizontal scroll on mobile by finding the element wider than the screen: a DevTools snippet, 8 causes with CSS fixes, and overflow data from 188 sites.
To fix horizontal scroll on mobile, find the element that's wider than the viewport and fix its sizing, rather than hiding the overflow. Run a snippet in DevTools that lists every element whose right edge passes document.documentElement.clientWidth, keep the outermost one (its parent fits, it doesn't), and correct the cause: a fixed width, a negative margin, an absolutely positioned or off-canvas element, a long unbroken URL, an image without max-width, or a wide table or code block.
It's more common than it looks, and it depends on width. On 9 October 2026 we loaded 188 random homepages in mobile emulation. 5.9% scrolled sideways at 390px (an iPhone-sized screen), and that doubled to 12.2% at 360px, a common Android width. Another 13.3% had content poking past the edge that was hidden only by overflow-x: hidden on <html> or <body>.
This guide gives you the snippet, a multi-width script, the eight causes we found with before/after CSS, and why overflow-x: hidden is a band-aid.
Why a Page Scrolls Sideways
The browser makes the page scrollable horizontally when the scrollable overflow of the root element is wider than the viewport. Any element that extends past the right edge contributes, unless an ancestor clips it with overflow: hidden, clip or auto. Elements moved with transform contribute too: a menu translated 100% off-screen to the right still widens the page.
On a phone this shows up as a page that wobbles when you swipe vertically, a gap down the right side, or a layout that zooms out on load. It also fails WCAG 2.2 Success Criterion 1.4.10 Reflow, which requires content to work at 320 CSS pixels wide without two-dimensional scrolling.
What We Found on 188 Mobile Homepages
The sample was the same 219 random homepages used in our website accessibility statistics (Tranco 94GG2, ranks 1,001–50,000), of which 188 loaded in mobile emulation. Each was loaded in Chromium with BugViso's iPhone 16 Pro profile (390×844, touch, mobile user agent) and then resized to 360px.
| Measurement | Homepages | Share |
|---|---|---|
| Scrolls horizontally at 390px | 11 | 5.9% |
| Scrolls horizontally at 360px | 23 | 12.2% |
Use overflow-x: hidden/clip on <html> or <body> | 50 | 26.6% |
| …with content still extending past the edge (hidden overflow) | 25 | 13.3% |
| Median overflow when a page scrolls | 16px |
Most overflow is a sliver. The median scrolling page was only 16px too wide, which is just enough to make the page feel loose under your thumb. A few were dramatic: one page was 432px too wide, more than a whole extra screen.
The cause of the outermost offending element, among the 23 pages that scrolled at 360px:
| Cause | Pages |
|---|---|
| Absolutely or fixed-positioned element past the edge | 15 |
| Negative margin (often a "full-bleed" hack) | 8 |
| Fixed width wider than the screen | 6 |
| Flex or grid child that won't shrink | 6 |
| Transformed element (off-canvas menu, animation start state) | 5 |
Media without max-width, min-width too large | 2 each |
| Long unbreakable word or URL | 1 |
A page can have more than one cause, so the counts add up to more than 23. Positioned elements dominate: decorative shapes, badges, dropdowns and slide-out panels placed with right: -40px or left: 100%.
Step 1: Find the Culprit in DevTools
Open DevTools, toggle the device toolbar (Ctrl/Cmd + Shift + M), pick a 360px-wide device, and paste this into the Console:
// Lists the OUTERMOST elements crossing the viewport edge, skipping ones an ancestor clips.
(() => {
const vw = document.documentElement.clientWidth;
const clipped = el => { for (let p = el.parentElement; p && p !== document.body; p = p.parentElement)
if (getComputedStyle(p).overflowX !== 'visible') return true; return false; };
const rows = [];
for (const el of document.querySelectorAll('body *')) {
const r = el.getBoundingClientRect();
if (!r.width || (r.right <= vw + 1 && r.left >= -1)) continue;
const pr = el.parentElement !== document.body && el.parentElement.getBoundingClientRect();
if (pr && (pr.right > vw + 1 || pr.left < -1)) continue; // parent already overflows
if (clipped(el)) continue;
el.style.outline = '2px solid red';
rows.push({ element: el, overflowPx: Math.round(Math.max(r.right - vw, -r.left)) });
}
console.log(`viewport ${vw}px, document ${document.documentElement.scrollWidth}px`);
console.table(rows);
})();Every culprit gets a red outline, and the table links to the live element (click it to jump to the Elements panel). If the table is empty but the page still scrolls, check for a pseudo-element (::before/::after), which querySelectorAll can't see. Toggle suspicious rules in the Styles pane instead.
If you suspect a parent has overflow-x: hidden hiding the problem, run this first, then the snippet again:
document.documentElement.style.overflowX = document.body.style.overflowX = 'visible';Step 2: Fix the Cause (8 Patterns)
1. Positioned element hanging off the edge
/* ❌ Decorative badge pokes 40px past the right edge */
.hero-badge { position: absolute; right: -40px; }
/* ✅ Keep it inside, or clip it at the component, not the page */
.hero { position: relative; overflow: clip; } /* clip doesn't create a scroll container */
.hero-badge { position: absolute; right: 0; }overflow: clip (unlike hidden) doesn't create a scroll container, so position: sticky inside it keeps working. Use it at the component level, where the overflow is intentional.
2. Negative-margin "full-bleed" sections
/* ❌ Classic full-bleed hack: overshoots when the parent has padding or a scrollbar */
.full-bleed { margin-left: -50vw; margin-right: -50vw; left: 50%; position: relative; width: 100vw; }
/* ✅ Grid-based full bleed: no negative margins, no vw maths */
.page { display: grid; grid-template-columns: 1fr min(65ch, 100% - 2rem) 1fr; }
.page > * { grid-column: 2; }
.page > .full-bleed { grid-column: 1 / -1; }3. Fixed widths
/* ❌ */ .promo { width: 420px; }
/* ✅ */ .promo { width: 100%; max-width: 420px; }Search your CSS for width: and min-width: values above 320px that aren't inside a media query. Each one is a candidate.
4. Flex and grid children that won't shrink
Flex items default to min-width: auto, so they won't shrink below their content's minimum size. A long word, a wide image or a nested flex row inside them pushes the row wider than the screen.
/* ✅ Allow flex/grid children to shrink below their content size */
.card-row > * { min-width: 0; }
.layout { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); } /* not 1fr 2fr */5. Off-canvas menus and transforms
/* ❌ Hidden menu sits translated off-screen and still widens the page */
.drawer { position: absolute; top: 0; right: 0; width: 80vw; transform: translateX(100%); }
/* ✅ Fixed positioning takes it out of the page's scrollable area; hide it from AT too */
.drawer { position: fixed; inset: 0 0 0 auto; width: 80vw; transform: translateX(100%); visibility: hidden; }
.drawer.is-open { transform: none; visibility: visible; }6. Long words, URLs and email addresses
/* ✅ Break long tokens only when they'd overflow */
.content { overflow-wrap: anywhere; } /* or break-word for older engines */
.content a { word-break: break-word; }One page in our sample scrolled 75px sideways at 390px because of long URLs printed as link text. overflow-wrap fixes this without hyphenating normal words.
7. Images, video and embeds
/* ✅ Media never exceeds its container */
img, video, iframe, canvas, svg { max-width: 100%; height: auto; }For third-party <iframe> embeds with fixed width attributes, wrap them in a container with aspect-ratio and set the iframe to width: 100%.
8. Tables and code blocks
/* ✅ Scroll the component, not the page */
.table-wrap, pre { overflow-x: auto; max-width: 100%; }Wrap wide tables in a <div class="table-wrap" tabindex="0" role="region" aria-label="Pricing table"> so keyboard users can scroll it too. WCAG's reflow criterion explicitly allows two-dimensional scrolling for data tables and code.
Why overflow-x: hidden on body Is a Band-Aid
Adding body { overflow-x: hidden; } makes the scrollbar go away. 26.6% of the homepages in our sample did exactly that, and on half of those, content was still sticking out past the edge, just invisible. The problems with this "fix":
- Content gets cut off. Whatever overflowed is now clipped: the end of a button, a menu item or a form field.
- It breaks
position: sticky.overflow: hiddenon an ancestor makes it the scroll container, so sticky headers inside stop sticking. - Mobile browsers don't always honour it on
<body>. In some mobile browsers,overflow-x: hiddenonbodyalone doesn't stop sideways panning. Teams then add it tohtmltoo and lose sticky positioning everywhere. - It hides future regressions. The next overflow nobody notices is also clipped, so the page never visibly breaks until a user can't reach a button.
If you need page-level clipping as a stopgap, overflow-x: clip on the specific section is safer than hidden on the body. Treat it as temporary and fix the element.
Check Every Width With One Script
DevTools tests one width at a time. This Playwright script checks several widths per URL, lists the outermost offenders with a probable cause, and then re-checks with overflow-x forced visible to reveal hidden overflow:
#!/usr/bin/env python3
"""find_overflow.py: find the elements that make a page scroll sideways on phones.
Usage:
pip install playwright && playwright install chromium
python3 find_overflow.py https://example.com [https://example.com/pricing ...] [--widths 320 360 390 412]
"""
import argparse, asyncio
from playwright.async_api import async_playwright
PROBE = r"""() => {
const vw = document.documentElement.clientWidth;
const clipped = (el) => { for (let p = el.parentElement; p && p !== document.body && p !== document.documentElement; p = p.parentElement)
if (getComputedStyle(p).overflowX !== 'visible') return true; return false; };
const label = (el) => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') +
(typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '');
const why = (el, r) => { const st = getComputedStyle(el), p = el.parentElement;
if (/^(IMG|VIDEO|IFRAME|CANVAS|svg|EMBED|OBJECT)$/.test(el.tagName)) return 'media without max-width';
if (el.tagName === 'TABLE') return 'wide table';
if (el.tagName === 'PRE' || el.closest('pre')) return 'unwrapped code block';
if (st.transform !== 'none') return 'transform (off-canvas / animation start state)';
if (st.position === 'absolute' || st.position === 'fixed') return 'positioned element';
if (parseFloat(st.marginLeft) < 0 || parseFloat(st.marginRight) < 0) return 'negative margin';
if (parseFloat(st.minWidth) > vw) return 'min-width wider than screen';
if (p && /flex|grid/.test(getComputedStyle(p).display)) return 'flex/grid child that will not shrink';
if ((el.textContent || '').split(/\s+/).some(w => w.length > 25) && st.overflowWrap === 'normal') return 'long unbreakable word/URL';
return 'fixed width'; };
const out = [];
for (const el of document.querySelectorAll('body *')) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0 || (r.right <= vw + 1 && r.left >= -1)) continue;
const st = getComputedStyle(el); if (st.visibility === 'hidden' || st.display === 'none') continue;
const pr = el.parentElement && el.parentElement !== document.body ? el.parentElement.getBoundingClientRect() : null;
if (pr && (pr.right > vw + 1 || pr.left < -1)) continue; // report the outermost offender only
if (clipped(el)) continue;
out.push({el: label(el), px: Math.round(Math.max(r.right - vw, -r.left)), why: why(el, r)});
if (out.length >= 8) break;
}
return {vw, scrollW: document.documentElement.scrollWidth, items: out};
}"""
UNMASK = "() => { document.documentElement.style.setProperty('overflow-x','visible','important'); document.body.style.setProperty('overflow-x','visible','important'); }"
async def check(browser, url, width):
ctx = await browser.new_context(viewport={"width": width, "height": 800}, device_scale_factor=2,
is_mobile=True, has_touch=True)
page = await ctx.new_page()
await page.goto(url, wait_until="load", timeout=45000)
await page.wait_for_timeout(1500)
before = await page.evaluate(PROBE)
await page.evaluate(UNMASK)
await page.wait_for_timeout(200)
after = await page.evaluate(PROBE)
await ctx.close()
return before, after
async def main(urls, widths):
async with async_playwright() as pw:
browser = await pw.chromium.launch()
for url in urls:
print(f"\n{url}")
for w in widths:
before, after = await check(browser, url, w)
scrolls = before["scrollW"] > before["vw"] + 1
hidden = not scrolls and after["scrollW"] > after["vw"] + 1
state = (f"✗ scrolls sideways by {before['scrollW'] - before['vw']}px" if scrolls else
f"⚠ hidden overflow: {after['scrollW'] - after['vw']}px clipped by overflow-x" if hidden else "✓ fits")
print(f" {w}px {state}")
for i in (before if scrolls else after)["items"] if (scrolls or hidden) else []:
print(f" +{i['px']:>4}px {i['el'][:60]:60s} {i['why']}")
await browser.close()
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("urls", nargs="+")
ap.add_argument("--widths", nargs="+", type=int, default=[320, 360, 390, 412])
a = ap.parse_args()
asyncio.run(main(a.urls, a.widths))Real output for two homepages from our sample (9 October 2026, names replaced):
https://ip-tools.example/
360px ✗ scrolls sideways by 461px
+ 390px div#ad_container negative margin
+ 392px div#container.clearfix negative margin
390px ✗ scrolls sideways by 432px
+ 360px div#ad_container negative margin
+ 362px div#container.clearfix negative margin
https://furniture-shop.example/
360px ⚠ hidden overflow: 20px clipped by overflow-x
+ 380px nav#js-product-nav.product-nav.has-motd positioned element
+ 5px a.back-to-top.js-back-to-top positioned element
+ 20px button.form-control.btn positioned element
390px ⚠ hidden overflow: 15px clipped by overflow-x
+ 405px nav#js-product-nav.product-nav.has-motd positioned elementThe second site looks fine to visitors, but a positioned navigation panel and a button cross the edge, and overflow-x is hiding them. That last button is probably cut off for real users.
How BugViso Detects Horizontal Overflow
BugViso's mobile pass loads every scanned page in Chromium emulating an iPhone 16 Pro (390×844, DPR 3, touch, mobile user agent) and runs a mobile-friendliness check. It flags horizontal overflow when the document's scroll width exceeds the viewport, and reports the element whose right edge extends furthest past it, with its selector and how many pixels it overshoots. The same pass checks the viewport meta tag, tap-target sizes against WCAG 2.5.8, and touch-target collisions, and records mobile Core Web Vitals.
One honest caveat: the reported element is the furthest-right one, which is sometimes a slide inside a clipped carousel rather than the element actually causing the scroll. When that happens, run the DevTools snippet above on the same page, because it walks up to the outermost unclipped offender. BugViso also checks a single width (390px). Since overflow doubled between 390px and 360px in our data, test narrower widths with the script.
You can run a free mobile check with BugViso, and work through the mobile SEO audit checklist for everything else on small screens. Everything in the mobile pass is listed on the WCAG accessibility and mobile checks page.
Traps and Edge Cases
100vwincludes the scrollbar on desktop.width: 100vwis wider than the page whenever a classic scrollbar is showing, which adds sideways scroll on Windows desktops. Phones use overlay scrollbars, so the bug only shows on desktop. Usewidth: 100%.- Animations start off-screen. Elements animated in from
translateX(100%)widen the page until the animation runs, which may be never if it's triggered by scrolling into view. vwunits in fonts.font-size: 6vwheadings can produce words wider than the container on narrow screens. Clamp them:font-size: clamp(1.75rem, 6vw, 3.5rem).- Zoomed-out pages. If the viewport meta tag is missing, mobile browsers render at a 980px layout width and zoom out. That's not overflow, but it looks like it. Check for
<meta name="viewport" content="width=device-width, initial-scale=1">. - Test at 320px. WCAG's reflow test uses 320 CSS pixels (the width of a 1280px window at 400% zoom), and it catches problems that 360px misses.
FAQ
How do I find which element is causing horizontal scroll?
Run the DevTools snippet in this guide on a narrow device preset. It lists and outlines the outermost elements that cross the viewport edge, skipping anything already clipped by a parent.
Should I use overflow-x: hidden on the body?
Only as a temporary stopgap. It hides the symptom, clips content, breaks position: sticky and doesn't work reliably on every mobile browser. Fix the element that's too wide.
Why does my site scroll sideways on Android but not on iPhone?
Common Android widths (360px, 384px, 412px) differ from iPhone widths (375px–440px). In our data, twice as many homepages overflowed at 360px as at 390px. Always test the narrowest width your audience uses.
Does horizontal scroll affect SEO?
Google uses mobile-first indexing, so it evaluates your mobile layout, and content that's hard to reach on a phone hurts usability. It's also a WCAG 2.2 reflow failure, which matters for accessibility compliance.
Conclusion
Horizontal scroll is almost always one element that's too wide: find the outermost offender, fix its width, margin or position, and stop hiding it with overflow-x: hidden. A BugViso mobile scan flags the overflow on every page it audits.
See where your site stands
Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.