Skip to content We're live on Product Hunt today Upvote
install

notes 6 min layout

note

What makes a page scroll sideways

overflow-x: hidden on body is not a fix. It is a way of not knowing which element is forty pixels too wide — and it quietly breaks position: sticky on everything inside it on the way past.

Sideways scroll is one of the few layout bugs with a single cause. Something is wider than the viewport, or starts far enough right that its edge lands outside it. There is no cascade to reason about and no specificity to lose to. There is one element, and the work is finding it.

It is also the bug most often closed without being found, because hiding it takes one line and looks identical in the screenshot.

The handful of things that actually do it #

100vw
On a desktop browser with a classic scrollbar, 100vw is the viewport including the scrollbar; the content box you are comparing it against is not. A full-bleed section overflows by around fifteen pixels — enough to scroll, small enough to read as a rendering fault.
min-width: auto
The default on flex and grid children, and it means do not shrink below your content. A long word, a wide table or an unbroken URL in a grid cell will push the whole track. Responsible for more of these than everything else here combined.
Fixed pixel widths
A width: 480px card in a 390px viewport. Obvious in review, invisible on the desktop it was written on.
Negative margins
A full-bleed trick pulling content out past the right edge, usually correct on one breakpoint and not the others.
Absolute positioning
right: -40px on a decorative shape. The element is out of flow, but not out of the scroll area.
Unbreakable strings
A URL, a hash, a wallet address, a base64 blob. No spaces means no wrap opportunity, unless overflow-wrap: anywhere says otherwise.
width: 100%
width: 100vw
width: 100%
+15px
Three rows in a frame. Two are sized to the content box; the middle one is sized to 100vw, which on a desktop browser includes the scrollbar the content box does not get. Fifteen pixels, and the page scrolls.

Worth knowing what does not do it: box-shadow and outline paint outside the box without contributing to scroll width. transform: translateX does contribute, even though the layout position has not moved.

Why the outline trick misleads #

the advice everybody gives
* { outline: 1px solid red; }

It works often enough to stay in circulation, and it is wrong in a specific way: it marks every element, so the offender and its dozen innocent descendants all appear to stick out together. You end up staring at the deepest one — a <span> inside the thing that is actually too wide — because it is the one whose red box is easiest to see.

It also cannot see anything already clipped by an ancestor with overflow: hidden set for a legitimate reason, which is exactly where these hide.

Measure edges instead #

The reliable test is arithmetic, not colour. Compare every element's right edge against the document width, then take the shallowest element that fails:

the whole diagnosis, in five lines
const limit = document.documentElement.clientWidth;

[...document.querySelectorAll('*')]
  .filter((el) => el.getBoundingClientRect().right > limit)
  .slice(0, 5);

// The first result is the cause.
// Everything after it is a passenger inside the cause.

The same pass catches the left side, which people forget exists. An element at a negative offset does not always produce scroll — a browser does not scroll into inline-start overflow in a left-to-right document — but it does in a right-to-left one. Which is how this ships broken in Arabic and Hebrew after passing every check in English.

The two fixes for the two commonest causes #

Once the element is identified, the repair is nearly always one of two lines. Neither is clever, and both are worth knowing by heart.

full bleed without the scrollbar arithmetic
.bleed {
  width: 100dvw;
  margin-inline: calc(50% - 50dvw);
}

/* Or skip the unit question entirely: */
.bleed-grid {
  grid-column: 1 / -1;
}
letting a flex or grid child actually shrink
.track > * {
  min-width: 0;
}

.track pre,
.track code {
  overflow-wrap: anywhere;
}

/* min-width: auto is the default and it means
   "never shrink below my content". A table, a long
   URL or a wide <pre> will push the whole track. */

min-width: 0 on flex and grid children is the single highest-value line in responsive CSS, and it is invisible until the day a long word arrives. Some teams set it globally on grid and flex children and never see this class of bug again.

Catching it before a person does #

This bug has an unusual property among layout bugs: it is trivially detectable in CI. It is one comparison of two numbers, it needs no screenshots and no visual diffing, and it fails deterministically.

the assertion, at three widths
for (const width of [320, 768, 1280]) {
  await page.setViewportSize({ width, height: 900 });
  const scrolls = await page.evaluate(
    () => document.documentElement.scrollWidth >
          document.documentElement.clientWidth,
  );
  expect(scrolls, `scrolls sideways at ${width}px`).toBe(false);
}

320 is the width WCAG asks a page to reflow to, so that row is a conformance check as well as a layout one. Adding those three lines is usually the last time anybody thinks about horizontal overflow in that codebase.

When hiding it is the correct answer #

Sometimes it genuinely is: a decorative shape deliberately drawn past the edge, a carousel whose track is meant to be wider than its frame. In both cases the clip belongs on that element or its immediate container — never on body or html.

The reason is not tidiness. overflow on the scroll container is what makes position: sticky stop working, and the failure surfaces somewhere else entirely, weeks later, as a header that will not stick. A clip scoped to the thing being clipped costs nothing and takes nothing else down with it.

If you inherit a codebase with overflow-x: hidden on body, remove it before debugging anything sticky. You are not looking for a sticky bug. You are looking for the overflow it was hiding.

Written by Ján Turský

Building LoupeKit and other browser tools out of Bratislava, under Apptiary.

tools in this note