slop audit
Blog post 43 / Responsive design

Horizontal overflow CSS: find and fix mobile scrolling

Debug horizontal overflow CSS on deployed pages, fix flex and grid sizing, contain wide content, and test mobile layouts for regressions before launch.

By Slop Audit · 9 min read

Horizontal overflow CSS bugs make a page wider than its viewport, leaving visitors to scroll sideways or miss a control. Find the element that exceeds the available width, trace its sizing constraints, and fix the component that owns the layout. Hiding the page's scrollbar can conceal the same broken content.

This guide gives developers a repeatable debugging process for a portfolio, documentation page, student project, or product site. Start on the deployed URL: long content, late-loading assets, and production styles can expose a problem that a short local fixture misses.

Horizontal overflow CSS checklist

  1. Reproduce the problem at a specific viewport width and page state.
  2. Compare the document's scroll width with its visible width.
  3. Find elements that extend past either horizontal edge.
  4. Inspect the candidate and its ancestors for fixed widths, minimum sizes, gaps, and positioning.
  5. Fix the responsible constraint or create an intentional local scrolling region.
  6. Verify that text, focus indicators, and controls remain reachable.
  7. Repeat with long content, open overlays, and browser zoom.
  8. Add a regression test against the deployed page.

If the entire mobile page looks like a scaled-down desktop, check the viewport meta tag first. This article assumes that the browser already uses the intended viewport and focuses on CSS sizing inside it.

Find the element causing horizontal scrolling

Open browser developer tools, select a narrow viewport, and return the page to its leftmost scroll position. Record the URL, width, and state: for example, “pricing page, 375 CSS pixels, annual billing selected.” Then run:

const root = document.documentElement;
console.table({
  visibleWidth: root.clientWidth,
  scrollWidth: root.scrollWidth,
  extraWidth: root.scrollWidth - root.clientWidth,
});

A positive difference is a useful document-level signal. It does not identify the cause or tell you whether a nested table scrolls correctly. MDN's scrollWidth reference explains that the measurement includes content outside the visible area and can include pseudo-elements.

Use this console helper to identify candidate boxes:

const width = document.documentElement.clientWidth;
const candidates = [...document.querySelectorAll('body *')]
  .map((element) => ({
    element,
    rect: element.getBoundingClientRect(),
  }))
  .filter(({ rect }) =>
    rect.width > 0 &&
    (rect.left < -1 || rect.right > width + 1)
  );

console.table(candidates.map(({ element, rect }) => ({
  element,
  left: Math.round(rect.left),
  right: Math.round(rect.right),
  width: Math.round(rect.width),
})));

This is a shortlist for inspection, not an automated verdict. An intentionally closed off-canvas menu may appear, and a pseudo-element can overflow without producing its own row. Nested descendants may all be reported because one ancestor is too wide. Inspect the first relevant component, walk up its ancestor chain, and temporarily disable suspicious rules in the Styles panel.

Try a concrete diagnosis: a card is 600 pixels wide inside a 343-pixel content column. If removing the card's min-width: 600px returns the document to the viewport width, you have a reproducible cause. Save the selector, old rule, test width, and observed result before editing the stylesheet.

Fix flexbox and grid minimum sizing

A flex child can refuse to shrink because its automatic minimum width is based on its content. A long repository URL inside a card is a common trigger. MDN describes the content-based automatic minimum for flex and grid items in its min-width reference.

For a row containing an icon and flexible text, allow the text item to shrink and give long tokens a wrapping policy:

.project-row {
  display: flex;
  gap: 0.75rem;
}

.project-row__icon {
  flex: 0 0 2rem;
}

.project-row__content {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
}

Apply the change to the item that needs to shrink. If that item sits inside another flex or grid child, inspect the outer child's minimum size too. Setting min-width: 0 on a distant descendant may leave the restrictive ancestor untouched.

A two-column grid can also grow around wide content. Give the flexible track a zero minimum and switch to one column when two columns no longer fit:

.docs-layout {
  display: grid;
  grid-template-columns: 16rem minmax(0, 1fr);
  gap: 2rem;
}

.docs-layout__article {
  min-width: 0;
}

@media (max-width: 48rem) {
  .docs-layout {
    grid-template-columns: minmax(0, 1fr);
  }
}

The breakpoint is an example. Choose it by testing the sidebar, article, and gap together. A smaller minimum is useful only if the resulting content remains readable and usable.

Check widths, padding, and positioned elements

With the default content-box model, padding and borders add to the declared width. A component with width: 100% and horizontal padding can therefore exceed its parent. MDN's box-sizing documentation explains how border-box includes padding and borders in the specified width.

A predictable baseline is:

*,
*::before,
*::after {
  box-sizing: border-box;
}

.page-shell {
  width: min(100% - 2rem, 72rem);
  margin-inline: auto;
}

Review 100vw separately. It sizes against the viewport rather than the parent content column and can create unwanted width where desktop scrollbars occupy space. A normal block often needs width: 100% or no explicit width at all. Do not swap units without checking the component's containing block.

Also inspect fixed minimum widths, negative margins, transforms, and absolutely positioned badges. A translated decoration or a close button positioned outside a dialog can cross the viewport edge even when the main column fits. Constrain the responsible element, then reopen the dialog at the failing width to verify the full control remains available.

Wrap long text and constrain media

Use a wrapping rule where visitors may supply long words, filenames, URLs, or identifiers:

.user-content {
  overflow-wrap: anywhere;
}

.article img,
.article video {
  max-width: 100%;
  height: auto;
}

MDN's overflow-wrap reference explains that anywhere permits breaks inside otherwise unbreakable strings and takes those breaks into account for minimum-content sizing. Review the result with real text. Code, account identifiers, and tabular data may need preserved formatting instead of arbitrary wrapping.

For images, inspect both the rendered size and the selected source. Width containment prevents a layout spill, but it does not ensure that the downloaded image is efficient. Follow the website image optimization checklist for responsive sources and asset sizing.

Keep wide tables and code in local scroll regions

A comparison table or code sample may need its own horizontal scroll area. Keep that region inside the content column so the rest of the page can reflow:

<div class="table-scroll" role="region"
     aria-label="Plan comparison" tabindex="0">
  <table>
    <caption>Features available in each plan</caption>
    <!-- Table headings and rows go here. -->
  </table>
</div>
.table-scroll {
  max-width: 100%;
  overflow-x: auto;
}

.table-scroll:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

Test keyboard scrolling, the visible focus indicator, and access to every column. Give users a visible cue that more content is available when the scrollbar is unobvious. Avoid adding named regions and tab stops to every minor box; use them where a meaningful scrolling area needs keyboard access. MDN's overflow accessibility guidance discusses focusable scroll containers and accessible context.

Do not apply overflow-x: hidden to the whole document as the first fix. Clipping can make the screenshot look tidy while cutting off text, a focus ring, or a button. Use intentional clipping for decorative content only after checking that useful content and interaction remain intact.

Test reflow and dynamic states

Test the initial page, open navigation, validation errors, consent notices, and dialogs. Use long fixture content and allow fonts and images to load. A layout that fits before a menu opens can still fail during the main task.

Include a 320 CSS pixel width and a desktop zoom check. W3C's Reflow guidance for WCAG 2.2 describes presenting vertically scrolling content without loss of information or functionality and without requiring two-dimensional scrolling at a width equivalent to 320 CSS pixels. It allows exceptions for content that needs a two-dimensional layout, such as some data tables. A nested scroll region does not excuse ordinary page text from reflowing.

A 375-pixel screenshot cannot establish the complete reflow requirement. Review zoom, content preservation, and exceptions separately. Use the mobile website testing guide to complete the visitor's task and the keyboard accessibility checklist to verify access to controls and scrolling regions.

Add a horizontal overflow CSS regression test

Run a Playwright test at representative narrow widths. Wait for a meaningful page element, then poll the document geometry so a transient initial measurement is not the only observation:

import { expect, test } from '@playwright/test';

for (const width of [320, 375, 768]) {
  test(`pricing fits at ${width}px`, async ({ page }) => {
    await page.setViewportSize({ width, height: 900 });
    await page.goto('https://preview.example.com/pricing');
    await expect(
      page.getByRole('heading', { name: 'Plans and pricing' })
    ).toBeVisible();
    await page.evaluate(() => document.fonts.ready);

    await expect.poll(() => page.evaluate(() => {
      const root = document.documentElement;
      return root.scrollWidth - root.clientWidth;
    })).toBeLessThanOrEqual(1);

    const action = page.getByRole('link', { name: 'Start free' });
    await expect(action).toBeVisible();
    const box = await action.boundingBox();
    expect(box).not.toBeNull();
    expect(box!.x).toBeGreaterThanOrEqual(0);
    expect(box!.x + box!.width).toBeLessThanOrEqual(width + 1);
  });
}

Adapt the route and accessible names to your page. Playwright documents polling in its assertions guide. The one-pixel tolerance accommodates rounding; it should not mask a consistently clipped component.

The geometry assertions cover document width and one control's horizontal bounds. They do not prove that an ancestor has not clipped it or that another element does not cover it. Add route-specific tests for open states and complete the manual task. Keep the failing viewport and fixture in the website regression testing suite after the fix.

Check the deployed layout with Slop Audit

After publishing the CSS fix, run Slop Audit on the public URL. When Browser Run capacity is available, it measures document width against a 375 by 812 mobile viewport and reports unintended horizontal overflow. It also checks visible controls for ancestor clipping and fully off-screen horizontal positions, with rendered screenshots to support review.

Use those findings to prioritize a component for inspection, then repeat the debugging steps at the reported width. Slop Audit does not identify every CSS cause, open every menu or dialog, test all widths, or certify WCAG conformance. A skipped browser stage leaves a coverage gap; a passing sampled layout still needs your route-specific reflow and interaction tests.

For release sign-off, record the original overflow width, the responsible selector, the changed rule, and the retest result. Keep local scrolling intentional, page text readable, and the primary action reachable before calling the layout ready.