You open a modal. For one frame, before you can even read what it says, the page behind it flinches — the whole column of text shifts a few pixels to the right, like the browser flinched too.
Close the modal and it snaps back. Nobody styled that. Nobody asked for it. And if you build UI on Windows or Linux, you've shipped it to production without noticing, because on a Mac with the default trackpad scrollbars, it's nearly invisible. That's the part that makes this bug so sticky: the person who can see it and the person who has to fix it are usually not using the same operating system.
The fix everyone reaches for first
The standard explanation is that overflow: hidden on <body> — the thing every modal, drawer, and dropdown uses to stop the page scrolling behind it — also removes the vertical scrollbar. Removing the scrollbar hands its width back to the content. The content reflows into that space. The page shifts.
So people fix it the only way CSS used to allow: measure the scrollbar, then pay it back in padding.
function lockScroll() {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.paddingRight = `${scrollbarWidth}px`;
document.body.style.overflow = "hidden";
}
function unlockScroll() {
document.body.style.paddingRight = "";
document.body.style.overflow = "";
}
This works. It's also the entire reason body-scroll-lock, react-remove-scroll, and half a dozen modal libraries ship their own copy of this exact measurement. Every one of them is solving the same problem: find out how wide the scrollbar would have been, then fake its presence with padding so nothing moves.
Before you keep reading — guess how many pixels of padding a 100-line copy-pasted "scroll lock" utility is usually off by, on the one OS nobody tested it on. The answer is almost always "enough to notice," because scrollbar width isn't a constant. It's roughly 17px on Windows across Chrome and Firefox alike, 0px on a Mac with overlay scrollbars — and the measurement has to run before you lock, every single time, or it reads the wrong number.
Why the shift happens at all
Here's the part the padding hack never actually fixes, it just hides: a classic scrollbar — the opaque kind with a visible track, standard on Windows and Linux — is laid out inside the element's content box. The browser treats it the same way it treats a sidebar: it eats width from whatever would otherwise render there. Turn the scrollbar off, and the content box grows back to full width. Turn it on, and the content box shrinks to make room. The content isn't "jumping" — it's being resized, every time, and resized fast enough that it reads as a flinch.
macOS's default overlay scrollbars don't do this. They float on top of the content without claiming any layout space, so toggling them on and off never changes anything's width. That's the whole reason this bug is invisible to a developer who only ever tests on their own Mac: the platform they build on structurally can't reproduce the thing their users are reporting.
The padding-right hack works around the resize by adding back exactly the width the scrollbar would have taken — but it only works if you already know the scrollbar is about to disappear, and you measured it correctly, before it does. It's compensating for a resize instead of preventing one.
scrollbar-gutter: stable: tell the layout to stop flinching
CSS has a property for exactly this now, and the name says what it does: reserve the scrollbar's gutter, stably, whether a scrollbar is currently needed or not.
html {
scrollbar-gutter: stable;
}
Set this once, and the browser carves out the scrollbar's width in the layout permanently — for overflow: auto, scroll, and hidden. When your modal flips overflow to hidden to lock the page, the gutter is already reserved from before the modal ever opened. There's no width to give back, so there's nothing to resize, so there's nothing to flinch.
function lockScroll() {
document.body.style.overflow = "hidden"; // that's it — no measuring, no padding
}
function unlockScroll() {
document.body.style.overflow = "";
}
That's the whole fix for the layout-shift half of what a scroll-lock library does. You still need something to stop the page actually scrolling under a modal on touch devices — overflow: hidden alone doesn't catch every edge case there, and that part of a scroll-lock library can stay. But the width-measuring, padding-compensating dance that most of these libraries built their reputation on? scrollbar-gutter: stable makes it dead code.
There's a second value for layouts that center content and need symmetry on both sides:
html {
scrollbar-gutter: stable both-edges;
}
both-edges mirrors the reserved gutter to the opposite inline edge, so if space is held open on the right, matching space opens on the left too — useful when a scrollbar appearing on only one side would visibly knock a centered layout off-center.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
The part that's easy to get wrong: overlay scrollbars opt out of this too
Here's the honest asterisk, and it's the same one from earlier in reverse: scrollbar-gutter: stable only reserves space for classic scrollbars. MDN says it plainly — "When using overlay scrollbars, the gutter will not be present." On a system with overlay scrollbars, setting this property changes nothing, because there was never any layout space being given up in the first place.
That means you can't fully verify this fix by eyeballing your own Mac. You need a Windows or Linux machine, a Windows/Linux VM, or Chrome DevTools' device toolbar won't help either — it emulates viewport size, not the host OS's scrollbar rendering mode. The honest test is someone, or something, running an actual classic-scrollbar environment.
Is this safe to ship today?
Yes, with one small caveat worth knowing rather than discovering. Chrome and Edge have supported scrollbar-gutter since version 94 (2021), and Firefox since version 97 (2022) — this has quietly worked for most of your users for years. Safari was the holdout; it landed support in Safari 18.2, released in December 2024, which is also what pushed scrollbar-gutter to Baseline "newly available" that same month. If you need to support Safari versions older than 18.2, the property is simply ignored there — no error, no warning, the page just behaves exactly like it always did. That's a safe degrade, not a broken one, so there's no reason to gate this behind a feature query unless you want to show a different UI specifically for browsers that lack it.
The next time a modal makes your page twitch, you'll know exactly why — and now you know the one-line reason to stop paying JavaScript to measure a number CSS can just reserve for you.
Still carrying a scroll-lock library in your bundle for this exact problem? Go check what it actually does under the hood — there's a decent chance half of it just became unnecessary.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
📚 Read next
- Stop Using a JS Masonry Library. CSS Grid Lanes Does It Natively.
- Stop Writing Math.cos() in a Resize Listener
- Your 'Expand All' Button Is Quietly Causing Layout Thrashing
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)