윈도우에서 웹을 만들다 보면 한 번쯤 마주치는 버그가 있다. 모달이나 드로어를 띄우는 순간 배경 화면 전체가 오른쪽으로 스크롤바 너비만큼 밀리는 현상이다.
macOS는 기본이 오버레이 스크롤바라 잘 드러나지 않지만, 클래식 스크롤바가 기본인 윈도우에서는 꽤 거슬린다. 눈에 거슬리는 것으로 끝나지 않고 Core Web Vitals 지표인 CLS(Cumulative Layout Shift) 점수도 같이 깎아먹는다.
스크롤바가 차지하던 자리다
모달이 열릴 때 배경 스크롤을 막으려고 보통 body에 overflow: hidden을 준다. 이때 브라우저 내부에서는 이런 일이 일어난다.
- 모달이 열리기 전에는 뷰포트 오른쪽을 스크롤바 트랙이 차지하고 있다.
overflow: hidden이 걸리면 뷰포트가 더 이상 스크롤되지 않으므로 트랙이 사라진다.- 트랙이 쓰던 폭만큼
documentElement.clientWidth가 즉시 넓어진다. margin: 0 auto로 가운데 정렬돼 있던 헤더나 본문 컨테이너의 위치가 다시 계산된다.
가운데 정렬된 요소는 좌우 여백을 절반씩 나눠 갖기 때문에, 스크롤바 폭이 17px이면 요소는 그 절반인 8.5px만 오른쪽으로 이동한다. 전체 폭을 쓰는 요소는 이동하는 게 아니라 오른쪽 경계가 17px 늘어난다.
직접 측정해보기
// 모달 열기 전 / 후 각각 실행
console.log({
innerWidth: window.innerWidth,
clientWidth: document.documentElement.clientWidth,
gutter: window.innerWidth - document.documentElement.clientWidth
});
레이아웃 이동 자체는 PerformanceObserver로 잡을 수 있다. sources를 열어보면 어떤 DOM 노드가 얼마나 움직였는지까지 나온다.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log(entry.value, entry.sources);
}
}
}).observe({ type: 'layout-shift', buffered: true });
아래에서 직접 확인할 수 있다. 첫 두 탭은 CSS 첫 줄의 scrollbar-gutter 값만 다르고 나머지는 완전히 같다. 미리보기 안의 모달 열기 버튼을 누르면 실제 스크롤 락과 똑같이 body에 overflow: hidden이 걸리고, 그때의 clientWidth와 카드 위치가 콘솔에 찍힌다.
CSS의 auto를 stable로 직접 바꿔서 실행해 보면 이동량이 어떻게 0이 되는지 한 번에 보인다.
예전에 쓰던 방법과 그 한계
이 문제를 우회하려고 오래전부터 쓰던 방법은 스크롤 락을 걸 때 JS로 스크롤바 폭을 재서 body에 그만큼 padding-right를 넣어주는 것이다.
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = `${scrollbarWidth}px`;
position: fixed요소가 그대로 밀린다.body에만 패딩을 넣으므로 상단에 고정된 헤더나 오버레이는 보정을 받지 못한다. 결국 고정 요소마다 같은 오프셋을 하나씩 더 붙여야 한다.- 환경마다 값이 다르다. OS 설정, 마우스 연결 여부, 커스텀 스크롤바 스타일에 따라 0px이 되기도 하고 17px이 되기도 한다.
- 모달을 열고 닫을 때마다 스타일을 토글한다. 그때마다 레이아웃 재계산이 따라온다.
scrollbar-gutter: stable
이 계산을 CSS 한 줄로 대신해 주는 표준 속성이 scrollbar-gutter다.
html {
scrollbar-gutter: stable;
}
stable은 브라우저에게 "스크롤바가 실제로 보이는지와 무관하게, 스크롤바가 들어설 자리는 항상 비워둬라"고 지시한다. 스크롤바가 사라져도 그 폭은 레이아웃에 그대로 남아 있으므로 clientWidth가 변하지 않고, 따라서 가운데 정렬도 흔들리지 않는다. 위 데모의 두 번째 탭이 이 상태다.
어떤 overflow 값에서 자리가 유지될까
stable이 항상 자리를 잡아주는 건 아니다. 스크롤 컨테이너인 경우에만 해당된다. 헤드리스 크롬으로 overflow 값을 바꿔가며 측정한 결과는 이랬다. (스크롤바 폭 20px 기준)
overflow | scrollbar-gutter: auto | scrollbar-gutter: stable |
|---|---|---|
visible | 0px | 0px |
auto (내용 넘침) | 20px | 20px |
scroll | 20px | 20px |
hidden | 0px | 20px |
clip | 0px | 0px |
스크롤 락에 쓰는 overflow: hidden에서 자리가 그대로 유지된다는 게 핵심이다. 반대로 overflow: clip은 스크롤 컨테이너를 만들지 않기 때문에 stable을 걸어놔도 자리가 사라진다. 스크롤 락을 clip으로 바꾸면 이 해법이 조용히 깨지므로 주의해야 한다.
부수 효과
stable은 스크롤이 생기지 않는 짧은 페이지에도 빈 자리를 남긴다. 오른쪽에 쓰이지 않는 여백이 생긴다는 뜻이라 처음엔 손해처럼 보이지만, 실제로는 페이지마다 스크롤 유무가 달라서 라우팅할 때마다 가운데 정렬이 흔들리던 문제까지 같이 해결된다. 좌우 대칭이 필요하면 stable both-edges로 양쪽에 자리를 잡을 수도 있다.
남은 문제 — fixed 오버레이는 gutter를 덮지 못한다
scrollbar-gutter: stable을 넣고 나면 새로운 게 보인다. 모달 오버레이를 position: fixed; inset: 0으로 깔았는데 오른쪽 끝에 배경이 그대로 비치는 띠가 남는다.
position: fixed의 컨테이닝 블록은 초기 컨테이닝 블록이고, 여기에는 스크롤바(gutter)가 포함되지 않는다. 그래서 inset: 0은 gutter를 뺀 영역까지만 덮는다. 반면 vw 단위는 스크롤바를 포함한 값이다. 앞의 측정에서도 뷰포트 1000px, gutter 20px 환경에서 inset: 0 오버레이는 980px, width: 100vw 요소는 1000px로 나왔다.
위 데모의 세 번째 탭이 이 상황이다. CSS에서 right: 0 대신 아래 두 줄의 주석을 풀어보면 덮이지 않던 폭이 0이 된다.
/* 1. vw 단위는 스크롤바를 포함한다 */
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100%;
}
/* 2. gutter 폭을 변수로 노출하고 음수 오프셋을 준다 */
.overlay {
position: fixed;
inset: 0;
right: calc(var(--scrollbar-gutter-width, 0px) * -1);
}
2번을 쓴 이유는 같은 변수를 오버레이 말고도 고정 헤더나 하단 배너에 재사용할 수 있어서다. both-edges를 쓰거나 RTL을 함께 지원해야 할 때도 2번이 다루기 편했다.
변수는 한 번만 심으면 된다. scrollbar-gutter: stable 덕분에 이 값은 모달 상태와 무관하게 일정하므로, 모달을 열고 닫을 때마다 다시 잴 필요가 없다. 첫 페인트 전에 값을 넣기 위해 인라인 스크립트로 처리했다.
// app/layout.tsx
const SCROLLBAR_GUTTER_SCRIPT = `
(function () {
var root = document.documentElement;
var set = function () {
root.style.setProperty(
'--scrollbar-gutter-width',
window.innerWidth - root.clientWidth + 'px'
);
};
set();
window.addEventListener('resize', set);
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<head>
<script dangerouslySetInnerHTML={{ __html: SCROLLBAR_GUTTER_SCRIPT }} />
</head>
<body>{children}</body>
</html>
);
}
라이브러리와 겹치면 이번엔 반대로 밀린다
여기서 한 번 더 걸렸다. react-remove-scroll을 쓰는 Dialog 프리미티브는 스크롤 락을 걸면서 스스로 padding-right를 넣는다. 그런데 scrollbar-gutter: stable이 켜져 있으면 innerWidth - clientWidth는 여전히 gutter 폭 그대로다. 결국 이미 자리가 확보된 상태에서 패딩이 한 번 더 들어가고, 이번에는 콘텐츠가 왼쪽으로 밀린다.
여기서 주의할 게 있다. Radix는 이 보정을 끄는 prop을 밖으로 열어두지 않는다. Dialog.Content가 내부적으로 RemoveScroll을 감싸 쓰는데 removeScrollBar 옵션이 전달되지 않으므로, "옵션 하나 끄면 된다"는 식으로는 해결되지 않는다. 실제로는 셋 중 하나를 골라야 한다.
- 라이브러리가 보정 옵션을 열어둔 경우(직접
react-remove-scroll을 쓰거나 래핑한 사내 컴포넌트라면)removeScrollBar={false}로 끈다. react-remove-scroll이 주입하는padding-right를 CSS로 되돌린다. 이 라이브러리는--removed-body-scroll-bar-size변수도 같이 심어주므로 그 값을 이용해 상쇄할 수 있다.- 아예
scrollbar-gutter: stable을 빼고 보정을 라이브러리에 맡긴다.
실제 적용한 컴포넌트
사내 UI 패키지가 헤드리스 Dialog 프리미티브를 감싼 형태다. 오버레이와 팝업 양쪽에 같은 오프셋을 준 게 전부다.
// components/my-page/UserBarCodeDialog.tsx
import Barcode from 'react-barcode';
import { useCoupon } from '~/api/coupon/queries';
import { Accordion, Dialog } from '~/components/ui';
import { IssuedType, UserInfo } from '~/types';
import { formatUtils } from '~/utils/formatUtils';
interface Props {
isOpen: boolean;
onClose: () => void;
user: UserInfo;
}
export default function UserBarCodeDialog({ isOpen, onClose, user }: Props) {
const { userCoupons } = useCoupon();
const visibleCoupons = userCoupons.filter(
(coupon) =>
coupon.issuedType !== IssuedType.DISCOUNT_KEY &&
coupon.couponStatus !== 'USE_N' &&
coupon.couponStatus !== 'USE_C'
);
return (
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}>
<Dialog.Portal>
{/* gutter 폭만큼 오른쪽으로 더 확장해 fixed 요소가 밀리지 않게 한다 */}
<Dialog.Overlay className="motion-safe:data-[state=open]:animate-fade-in motion-safe:data-[state=closed]:animate-fade-out fixed inset-0 right-[calc(var(--scrollbar-gutter-width,0px)*-1)] z-50 bg-black/50" />
<Dialog.Popup className="motion-safe:data-[state=open]:animate-fade-in motion-safe:data-[state=closed]:animate-fade-out fixed inset-0 right-[calc(var(--scrollbar-gutter-width,0px)*-1)] z-50 overflow-auto bg-white">
<div className="flex justify-end p-4">
<Dialog.Close asChild>
<button
type="button"
aria-label="閉じる"
className="h-8.5 justify-self-end transition-opacity hover:opacity-70">
CLOSE
</button>
</Dialog.Close>
</div>
<div className="text-t3 flex flex-col items-center px-4">
<div className="flex w-full flex-col items-center border-y border-gray-300 py-4">
<p className="text-t2 text-dark">
<span className="font-semibold">{`${user.lastName}${user.firstName}`}</span>
さんの 情報です。
</p>
<Barcode value={String(user.userCode)} width={3.5} height={70} displayValue={false} />
</div>
<div className="text-t2 text-dark mt-5 flex w-full flex-col gap-5 font-medium">
<div className="flex justify-between">
生年月日
<p className="font-normal">{user.userBirth}</p>
</div>
<div className="flex justify-between">
携帯電話番号
<p className="font-normal">{formatUtils.phoneNumber(user.userPhone)}</p>
</div>
<div className="flex justify-between">
ポイント
<p className="font-normal">{(user.ownPoint ?? 0).toLocaleString()} ポイント</p>
</div>
<Accordion type="single">
<Accordion.Item
value="coupon"
title="クーポン"
className="font-normal"
buttonClassName="text-t2 text-dark py-0 font-medium"
extra={<span>{visibleCoupons.length}件</span>}>
<div className="flex flex-col gap-y-4 text-right font-normal">
{visibleCoupons.map((coupon) => (
<div key={`coupon-${coupon.couponId}`}>{coupon.couponName}</div>
))}
</div>
</Accordion.Item>
</Accordion>
</div>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
결과
윈도우 크롬에서 측정한 값이다. 스크롤바 폭은 17px이었다.
| 항목 | overflow: hidden만 | scrollbar-gutter: stable + 오프셋 |
|---|---|---|
clientWidth 변화 | 17px 확장 | 변화 없음 |
| 가운데 정렬 요소 이동 | 8.5px | 0px |
| fixed 오버레이 우측 여백 | 17px | 0px |
| CLS | 0.082 | 0.000 |
정리
- 모달을 열 때 화면이 밀리는 건
overflow: hidden으로 스크롤바 트랙이 사라지면서clientWidth가 넓어지기 때문이다. 가운데 정렬 요소는 스크롤바 폭의 절반만큼 움직인다. padding-right를 JS로 주입하는 방식은fixed요소를 보정하지 못하고 환경별 편차도 크다.html에scrollbar-gutter: stable을 걸면overflow: hidden상태에서도 자리가 유지된다. 단overflow: clip에는 적용되지 않는다.position: fixed요소는 gutter를 덮지 못하므로100vw나 음수 오프셋으로 따로 보정해야 한다.- 스크롤 락 라이브러리가 자체 패딩 보정을 하고 있다면 반드시 꺼야 한다. 안 그러면 이번엔 반대 방향으로 밀린다.