Blog
CSSJavaScriptWeb Development

Preventing Layout Shifts When Opening a Modal

Locking body scroll for a modal removes the scrollbar and shifts the page sideways. scrollbar-gutter fixes it in one line, and here is the JavaScript fallback for the cases it misses.

Dulan Katukurundage Published Updated

Open a modal on Windows Chrome, set overflow: hidden on the body to stop the page behind it scrolling, and watch the whole layout jump 7 or 8 pixels to the right before the modal finishes fading in. Close it and everything jumps back.

The cause is not the modal. It is the scrollbar you just deleted.

A classic scrollbar occupies real layout space, between 15 and 17 pixels depending on the platform and the browser. overflow: hidden removes the scrollbar, the content area gets that space back, and every element on the page relays out inside a container that is suddenly wider.

viewport 1280px, classic scrollbar 15px

  scrollbar present            scrollbar removed by overflow: hidden
  content area 1265px          content area 1280px

  centered 1200px container    centered 1200px container
  left edge at 32.5px          left edge at 40px          -> moves 7.5px right

  full-width header            full-width header
  right edge at 1265px         right edge at 1280px       -> moves 15px right

Centered content moves by half the scrollbar width. Anything anchored to the right edge moves by the whole of it. The two move by different amounts at the same time, which is why it reads as a lurch rather than a slide.

This Is Not a CLS Problem

Worth getting out of the way early, because it changes how much effort this deserves.

Cumulative Layout Shift ignores shifts that happen within 500ms of a discrete user input. Click, tap, and keypress all set the hadRecentInput flag on the shift, and flagged shifts are dropped from the score. A modal opened by a button click, which is essentially all of them, produces a shift that never reaches your Core Web Vitals.

So fixing this will not move a Search Console number. It is a visual quality problem, and the reason to fix it is that the jump is distracting and takes about a minute of work. If you are here because a report told you to fix CLS, the shift you are looking for is somewhere else, probably an image without dimensions or a font swap.

The exception: if your modal opens more than 500ms after the click, from a slow fetch, the shift lands outside the exclusion window and does count. Opening the shell immediately and filling it in afterwards avoids that, and is better UX anyway.

The One-Line Fix

scrollbar-gutter reserves the scrollbar's space whether or not a scrollbar is being drawn.

html {
  scrollbar-gutter: stable;
}

Set on the root element, the value propagates to the viewport, which is the behaviour you want and easy to miss, because the property otherwise applies to individual scrolling boxes.

The mechanism is exactly what the problem needs. With stable, the gutter is present whenever overflow is auto, scroll, or hidden, even when the box is not overflowing. That hidden in the list is the whole trick: the gutter survives the scroll lock, the content area width never changes, and nothing moves.

It also fixes the shift you get on ordinary navigation between a short page and a long one, which is the same bug wearing different clothes.

This has been Baseline since December 2024, with Chromium 94, Firefox 97, and Safari 18.2.

scrollbar-gutter: stable both-edges reserves a matching gutter on the opposite side, which keeps content optically centred rather than offset by half a scrollbar. It costs another 15px of width and is a design decision, not a correctness one.

Locking the Scroll Without JavaScript

If you are using the native <dialog> element, the lock itself can also be CSS.

showModal() makes the rest of the document inert and traps focus, but it does not stop the page behind it from scrolling. That part is still yours to handle. The :modal pseudo-class matches a dialog opened with showModal(), so :has() can drive the lock off it:

html {
  scrollbar-gutter: stable;
}

/* Any dialog opened with showModal() locks the page behind it. */
html:has(dialog:modal) {
  overflow: hidden;
}

No class toggling, no state to keep in sync, and nothing to get wrong when two modals close out of order. The lock follows the actual state of the DOM.

Note that :modal matches only dialogs opened with showModal(). A dialog shown with the open attribute or show() is non-modal by definition and will not match, which is correct behaviour rather than a limitation.

Where scrollbar-gutter Does Not Reach

Three cases, and the first two are not really problems.

Overlay Scrollbars

On macOS with the default trackpad setting, and on most touch devices, scrollbars are drawn over the content and consume no layout space. scrollbar-gutter has no effect there, because there is no gutter to reserve. There is also no shift to fix, since removing a scrollbar that occupied no space changes no widths. The property does nothing, and nothing is wrong.

Safari Before 18.2

Released in December 2024, so the affected population is small and shrinking. Those users still see the jump. It is a cosmetic degradation with no functional consequence, which for most projects is an acceptable place to stop.

100vw

This one is a genuine trap, and it predates the modal entirely. 100vw is the viewport width including the scrollbar gutter, so an element sized 100vw inside a gutter-reserved page overflows by the width of the gutter and creates a horizontal scrollbar. Use 100% on a block-level element, or 100dvw, and keep 100vw for the cases where you actually mean the full viewport.

The JavaScript Fallback

If you need the old browsers covered, or you cannot reserve a permanent gutter because the design depends on the page being exactly as wide as the viewport, measure the scrollbar and pay it back as padding.

The version that used to be in this article had two bugs, so here is a corrected one:

let lockCount = 0;
let previousOverflow = "";

export function lockScroll() {
  // Nested modals: only the outermost lock touches the DOM.
  if (lockCount++ > 0) return;

  // Measure at open time. A short page has no scrollbar and needs no padding.
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;

  previousOverflow = document.body.style.overflow;
  document.body.style.overflow = "hidden";
  document.documentElement.style.setProperty(
    "--scrollbar-width",
    `${scrollbarWidth}px`
  );
}

export function unlockScroll() {
  if (--lockCount > 0) return;

  // Restore what was there, rather than forcing a value.
  document.body.style.overflow = previousOverflow;
  document.documentElement.style.removeProperty("--scrollbar-width");
}
body {
  padding-right: var(--scrollbar-width, 0px);
}

What the original got wrong:

It measured the scrollbar width once at module load. On a page that starts short and grows past the viewport, the measurement is 0 and no padding is applied, so the shift comes back. Measuring inside lockScroll gives the width as it is when the modal actually opens.

It restored overflow to auto on close rather than to whatever was there before. The body's initial value is visible, and auto is not the same thing: auto establishes a scroll container and can produce a scrollbar on the body separate from the viewport's. Store the previous value and put it back, or remove the inline style entirely.

The lock counter is the third fix. Without it, a confirmation dialog opened from inside a modal unlocks the page when it closes, and the modal still on screen is suddenly scrollable behind its own backdrop.

Exposing the width as a custom property rather than writing padding-right directly means fixed elements can consume the same value, which is the next section.

Fixed Elements Need the Same Padding

Padding the body does nothing for anything positioned fixed. A sticky header pinned to the viewport is laid out against the viewport, not the body, so it widens by the full scrollbar width the moment the scrollbar goes away, while the padded body content stays put. The header now disagrees with the content underneath it by 15px.

Pay them the same way:

.site-header,
.floating-action-button {
  padding-right: var(--scrollbar-width, 0px);
}

Any element with position: fixed that touches the right edge of the screen needs this. It is the main reason the JavaScript approach is more work than it first appears, and the main argument for scrollbar-gutter, which fixes fixed elements for free because the viewport width never changed in the first place.

iOS Safari Is a Different Problem

overflow: hidden on the body does not reliably stop touch scrolling in iOS Safari. The user can still drag the page behind the modal, and on some versions the address bar collapses while they do it. None of the techniques above address this, because it is not a scrollbar problem. iOS uses overlay scrollbars, so there was never a shift to fix.

The usual workaround is to fix the body in place and restore the scroll position on close:

const scrollY = window.scrollY;
document.body.style.position = "fixed";
document.body.style.top = `-${scrollY}px`;
document.body.style.width = "100%";

// on close
document.body.style.position = "";
document.body.style.top = "";
document.body.style.width = "";
window.scrollTo(0, scrollY);

This works and it is unpleasant. It breaks position: sticky inside the page, it interacts badly with anything that reads scrollY while the modal is open, and the restore has to be exact or the user loses their place. Reach for a maintained library before hand-rolling it. This is the one part of modal scroll locking where the edge cases genuinely justify a dependency.

When This Is Wrong

If your layout has no elements anchored to the right edge and nothing centred, there is no visible shift and none of this buys you anything. Check before you build it.

If you are locking scroll for something that is not modal, a dropdown, a tooltip, an expanding panel, you probably should not be locking scroll at all. The lock is there to stop the user losing the modal behind the page. A dropdown that closes on scroll is a better answer than a page that refuses to move.

And if you are supporting overlay scrollbars only, macOS, iOS, Android, this entire article is about a bug you do not have. The one line of CSS is still worth adding for the Windows and Linux visitors in your analytics, but do not spend an afternoon on the JavaScript fallback for a shift nobody on your platform can see.

FAQs

Why does my page shift right instead of left when the modal opens?

The scrollbar sits on the right in left-to-right locales, so removing it extends the content area rightwards. Centred content moves right by half the scrollbar width and right-anchored content moves right by all of it. In a right-to-left document the scrollbar is on the left and everything moves the other way.

Does scrollbar-gutter: stable work with overflow: hidden?

Yes, and that is the specific behaviour that makes it useful here. With stable, the gutter is reserved when overflow is auto, scroll, or hidden, so the scroll lock no longer changes the content width.

Should I put scrollbar-gutter on html or body?

html. Set on the root element it propagates to the viewport, which is the scroll container that actually has the scrollbar. On body it applies to the body's own scrolling box, which is usually not scrolling and not the one you care about.

Does the native <dialog> element handle any of this?

It handles focus trapping, inertness of the rest of the page, and the backdrop. It does not stop the page behind it from scrolling, so you still need the lock, and locking still removes the scrollbar. html:has(dialog:modal) { overflow: hidden } plus scrollbar-gutter: stable covers both in CSS.

Is a permanent gutter a problem for wide layouts?

It costs 15 to 17 pixels of horizontal space on pages that do not scroll, which on a full-bleed hero is visible as a thin strip of page background on the right. stable both-edges makes it symmetric at twice the cost. If neither is acceptable, the JavaScript fallback is the trade you are making instead.