scrollbar-gutter

모달을 열 때 레이아웃이 옆으로 밀리는 원인을 직접 측정해보고, scrollbar-gutter: stable로 CLS를 없앤 기록

윈도우에서 웹을 만들다 보면 한 번쯤 마주치는 버그가 있다. 모달이나 드로어를 띄우는 순간 배경 화면 전체가 오른쪽으로 스크롤바 너비만큼 밀리는 현상이다.

macOS는 기본이 오버레이 스크롤바라 잘 드러나지 않지만, 클래식 스크롤바가 기본인 윈도우에서는 꽤 거슬린다. 눈에 거슬리는 것으로 끝나지 않고 Core Web Vitals 지표인 CLS(Cumulative Layout Shift) 점수도 같이 깎아먹는다.

스크롤바가 차지하던 자리다

모달이 열릴 때 배경 스크롤을 막으려고 보통 bodyoverflow: hidden을 준다. 이때 브라우저 내부에서는 이런 일이 일어난다.

  1. 모달이 열리기 전에는 뷰포트 오른쪽을 스크롤바 트랙이 차지하고 있다.
  2. overflow: hidden이 걸리면 뷰포트가 더 이상 스크롤되지 않으므로 트랙이 사라진다.
  3. 트랙이 쓰던 폭만큼 documentElement.clientWidth가 즉시 넓어진다.
  4. 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 값만 다르고 나머지는 완전히 같다. 미리보기 안의 모달 열기 버튼을 누르면 실제 스크롤 락과 똑같이 bodyoverflow: hidden이 걸리고, 그때의 clientWidth와 카드 위치가 콘솔에 찍힌다.

PREVIEW
CONSOLE
// 출력 없음

CSS의 autostable로 직접 바꿔서 실행해 보면 이동량이 어떻게 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 기준)

overflowscrollbar-gutter: autoscrollbar-gutter: stable
visible0px0px
auto (내용 넘침)20px20px
scroll20px20px
hidden0px20px
clip0px0px

스크롤 락에 쓰는 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 옵션이 전달되지 않으므로, "옵션 하나 끄면 된다"는 식으로는 해결되지 않는다. 실제로는 셋 중 하나를 골라야 한다.

  1. 라이브러리가 보정 옵션을 열어둔 경우(직접 react-remove-scroll을 쓰거나 래핑한 사내 컴포넌트라면) removeScrollBar={false}로 끈다.
  2. react-remove-scroll이 주입하는 padding-right를 CSS로 되돌린다. 이 라이브러리는 --removed-body-scroll-bar-size 변수도 같이 심어주므로 그 값을 이용해 상쇄할 수 있다.
  3. 아예 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: hiddenscrollbar-gutter: stable + 오프셋
clientWidth 변화17px 확장변화 없음
가운데 정렬 요소 이동8.5px0px
fixed 오버레이 우측 여백17px0px
CLS0.0820.000

정리

  • 모달을 열 때 화면이 밀리는 건 overflow: hidden으로 스크롤바 트랙이 사라지면서 clientWidth가 넓어지기 때문이다. 가운데 정렬 요소는 스크롤바 폭의 절반만큼 움직인다.
  • padding-right를 JS로 주입하는 방식은 fixed 요소를 보정하지 못하고 환경별 편차도 크다.
  • htmlscrollbar-gutter: stable을 걸면 overflow: hidden 상태에서도 자리가 유지된다. 단 overflow: clip에는 적용되지 않는다.
  • position: fixed 요소는 gutter를 덮지 못하므로 100vw나 음수 오프셋으로 따로 보정해야 한다.
  • 스크롤 락 라이브러리가 자체 패딩 보정을 하고 있다면 반드시 꺼야 한다. 안 그러면 이번엔 반대 방향으로 밀린다.